├── dtmimporter.zip ├── README.md └── dtmimporter ├── mesh ├── __init__.py ├── triangulate.py ├── dtm.py └── terrain.py ├── ui ├── __init__.py ├── terrainpanel.py └── importer.py ├── pvl ├── label.py ├── __init__.py ├── patterns.py └── parse.py └── __init__.py /dtmimporter.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/phaseIV/Blender-Hirise-DTM-Importer/HEAD/dtmimporter.zip -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Blender-Hirise-DTM-Importer 2 | 3 | Modified version of the original Hirise DTM importer addon that will work with Blender 2.8+. 4 | The original addon is written by Nicholas Wolf and Tim Spriggs. 5 | 6 | Instructions can be found [here](https://hemelmechanica.nl/hirise-docs/quickstart.html) 7 | 8 | Checkout this great video made by Waylena to see how to use this software. 9 | [https://fossdome.com/fun-with-blender-and-mars/](https://fossdome.com/fun-with-blender-and-mars/) 10 | -------------------------------------------------------------------------------- /dtmimporter/mesh/__init__.py: -------------------------------------------------------------------------------- 1 | # This file is a part of the HiRISE DTM Importer for Blender 2 | # 3 | # Copyright (C) 2017 Arizona Board of Regents on behalf of the Planetary Image 4 | # Research Laboratory, Lunar and Planetary Laboratory at the University of 5 | # Arizona. 6 | # 7 | # This program is free software: you can redistribute it and/or modify it 8 | # under the terms of the GNU General Public License as published by the Free 9 | # Software Foundation, either version 3 of the License, or (at your option) 10 | # any later version. 11 | # 12 | # This program is distributed in the hope that it will be useful, but 13 | # WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY 14 | # or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License 15 | # for more details. 16 | # 17 | # You should have received a copy of the GNU General Public License along 18 | # with this program. If not, see . 19 | 20 | """A sub-package for loading DTMs as 3D models""" 21 | 22 | from . import dtm 23 | from . import terrain 24 | 25 | __all__ = ['dtm', 'terrain', ] 26 | -------------------------------------------------------------------------------- /dtmimporter/ui/__init__.py: -------------------------------------------------------------------------------- 1 | # This file is a part of the HiRISE DTM Importer for Blender 2 | # 3 | # Copyright (C) 2017 Arizona Board of Regents on behalf of the Planetary Image 4 | # Research Laboratory, Lunar and Planetary Laboratory at the University of 5 | # Arizona. 6 | # 7 | # This program is free software: you can redistribute it and/or modify it 8 | # under the terms of the GNU General Public License as published by the Free 9 | # Software Foundation, either version 3 of the License, or (at your option) 10 | # any later version. 11 | # 12 | # This program is distributed in the hope that it will be useful, but 13 | # WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY 14 | # or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License 15 | # for more details. 16 | # 17 | # You should have received a copy of the GNU General Public License along 18 | # with this program. If not, see . 19 | 20 | """A sub-package for Blender UI elements""" 21 | 22 | from . import importer 23 | from . import terrainpanel 24 | 25 | __all__ = ['importer', 'terrainpanel', ] 26 | -------------------------------------------------------------------------------- /dtmimporter/pvl/label.py: -------------------------------------------------------------------------------- 1 | # This file is a part of the HiRISE DTM Importer for Blender 2 | # 3 | # Copyright (C) 2017 Arizona Board of Regents on behalf of the Planetary Image 4 | # Research Laboratory, Lunar and Planetary Laboratory at the University of 5 | # Arizona. 6 | # 7 | # This program is free software: you can redistribute it and/or modify it 8 | # under the terms of the GNU General Public License as published by the Free 9 | # Software Foundation, either version 3 of the License, or (at your option) 10 | # any later version. 11 | # 12 | # This program is distributed in the hope that it will be useful, but 13 | # WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY 14 | # or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License 15 | # for more details. 16 | # 17 | # You should have received a copy of the GNU General Public License along 18 | # with this program. If not, see . 19 | 20 | 21 | class Label(dict): 22 | """A dict-like representation of a PVL label""" 23 | def __init__(self, *args, **kwargs): 24 | super(Label, self).__init__(*args, **kwargs) 25 | -------------------------------------------------------------------------------- /dtmimporter/pvl/__init__.py: -------------------------------------------------------------------------------- 1 | # This file is a part of the HiRISE DTM Importer for Blender 2 | # 3 | # Copyright (C) 2017 Arizona Board of Regents on behalf of the Planetary Image 4 | # Research Laboratory, Lunar and Planetary Laboratory at the University of 5 | # Arizona. 6 | # 7 | # This program is free software: you can redistribute it and/or modify it 8 | # under the terms of the GNU General Public License as published by the Free 9 | # Software Foundation, either version 3 of the License, or (at your option) 10 | # any later version. 11 | # 12 | # This program is distributed in the hope that it will be useful, but 13 | # WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY 14 | # or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License 15 | # for more details. 16 | # 17 | # You should have received a copy of the GNU General Public License along 18 | # with this program. If not, see . 19 | 20 | """A sub-package for parsing PVL labels""" 21 | 22 | from .parse import LabelParser 23 | 24 | 25 | def load(path): 26 | """Returns a dict-like representation of a PVL label""" 27 | return LabelParser.load(path) 28 | -------------------------------------------------------------------------------- /dtmimporter/pvl/patterns.py: -------------------------------------------------------------------------------- 1 | # This file is a part of the HiRISE DTM Importer for Blender 2 | # 3 | # Copyright (C) 2017 Arizona Board of Regents on behalf of the Planetary Image 4 | # Research Laboratory, Lunar and Planetary Laboratory at the University of 5 | # Arizona. 6 | # 7 | # This program is free software: you can redistribute it and/or modify it 8 | # under the terms of the GNU General Public License as published by the Free 9 | # Software Foundation, either version 3 of the License, or (at your option) 10 | # any later version. 11 | # 12 | # This program is distributed in the hope that it will be useful, but 13 | # WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY 14 | # or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License 15 | # for more details. 16 | # 17 | # You should have received a copy of the GNU General Public License along 18 | # with this program. If not, see . 19 | 20 | """PVL Regular Expression Patterns""" 21 | 22 | import re 23 | 24 | # End of PVL File 25 | END = re.compile( 26 | r'\s* \bEND\b \s*', (re.VERBOSE + re.IGNORECASE) 27 | ) 28 | 29 | # PVL Comment 30 | COMMENT = re.compile( 31 | r'/\* .*? \*/', (re.DOTALL + re.VERBOSE) 32 | ) 33 | 34 | # PVL Statement 35 | STATEMENT = re.compile( 36 | r""" 37 | \s* (?P\w+) # Match a PVL key 38 | \s* = \s* # Who knows how many spaces we encounter 39 | (?P # Match a PVL value 40 | ([+-]?\d+\.?\d*) # We could match a number 41 | | (['"]?((\w+ \s*?)+)['"]?) # Or a string 42 | ) 43 | (\s* <(?P.*?) >)? # The value may have an associated unit 44 | """, re.VERBOSE 45 | ) 46 | 47 | # Integer Number 48 | INTEGER = re.compile( 49 | r""" 50 | [+-]?(?. 19 | 20 | """A HiRISE DTM Importer for Blender""" 21 | 22 | bl_info = { 23 | "name": "HiRISE DTM Importer", 24 | "author": "Nicholas Wolf (nicwolf@pirl.lpl.arizona.edu) / phaseIV", 25 | "version": (0, 2, 3), 26 | "blender": (2, 80, 0), 27 | "license": "GPL", 28 | "location": "File > Import > HiRISE DTM (.img)", 29 | "description": "Import a HiRISE DTM as a mesh", 30 | "warning": "May consume a lot of memory", 31 | "category": "Import-Export", 32 | "wiki_url": "", # TBD 33 | "tracker_url": "", # TBD 34 | "link": "", # TBD 35 | } 36 | 37 | if "bpy" in locals(): 38 | import importlib 39 | importlib.reload(importer) 40 | importlib.reload(terrainpanel) 41 | else: 42 | from .ui import importer 43 | from .ui import terrainpanel 44 | 45 | import bpy 46 | 47 | 48 | def menu_import(self, context): 49 | i = importer.ImportHiRISETerrain 50 | self.layout.operator(i.bl_idname, text=i.bl_label) 51 | 52 | 53 | classes = ( 54 | importer.ImportHiRISETerrain, 55 | terrainpanel.TerrainPanel, 56 | terrainpanel.ReloadTerrain, 57 | ) 58 | 59 | 60 | def register(): 61 | from bpy.utils import register_class 62 | for cls in classes: 63 | register_class(cls) 64 | bpy.types.TOPBAR_MT_file_import.append(menu_import) 65 | 66 | 67 | def unregister(): 68 | from bpy.utils import unregister_class 69 | for cls in reversed(classes): 70 | unregister_class(cls) 71 | bpy.types.TOPBAR_MT_file_import.remove(menu_import) 72 | 73 | 74 | if __name__ == '__main__': 75 | register() 76 | 77 | -------------------------------------------------------------------------------- /dtmimporter/pvl/parse.py: -------------------------------------------------------------------------------- 1 | # This file is a part of the HiRISE DTM Importer for Blender 2 | # 3 | # Copyright (C) 2017 Arizona Board of Regents on behalf of the Planetary Image 4 | # Research Laboratory, Lunar and Planetary Laboratory at the University of 5 | # Arizona. 6 | # 7 | # This program is free software: you can redistribute it and/or modify it 8 | # under the terms of the GNU General Public License as published by the Free 9 | # Software Foundation, either version 3 of the License, or (at your option) 10 | # any later version. 11 | # 12 | # This program is distributed in the hope that it will be useful, but 13 | # WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY 14 | # or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License 15 | # for more details. 16 | # 17 | # You should have received a copy of the GNU General Public License along 18 | # with this program. If not, see . 19 | 20 | """PVL Label Parsing""" 21 | 22 | import collections 23 | import re 24 | 25 | from . import patterns 26 | from .label import Label 27 | 28 | Quantity = collections.namedtuple('Quantity', ['value', 'units']) 29 | 30 | 31 | class PVLParseError(Exception): 32 | """Error parsing PVL file""" 33 | def __init__(self, message): 34 | super(PVLParseError, self).__init__(message) 35 | 36 | 37 | class LabelParser: 38 | """A PVL Parser""" 39 | @staticmethod 40 | def load(path): 41 | """ 42 | Load a dict-like representation of a PVL label header 43 | 44 | Parameters 45 | ---------- 46 | path : str 47 | Path to a file containing a PVL header 48 | 49 | Returns 50 | ---------- 51 | label : pvl.Label 52 | 53 | """ 54 | raw = LabelParser._read(path) 55 | return Label(**LabelParser._parse(raw)) 56 | 57 | @staticmethod 58 | def _read(path): 59 | """ 60 | Get the PVL header from a file as a string 61 | 62 | Parameters 63 | ---------- 64 | path : str 65 | Path to a file containing a PVL header 66 | 67 | Returns 68 | ---------- 69 | raw : str 70 | 71 | Notes 72 | --------- 73 | * This function assumes that the file begins with a PVL header 74 | and it will read lines from the file until it encounters 75 | a PVL end statement. 76 | 77 | To-Do 78 | --------- 79 | * This could be more robust. What happens if there is no label 80 | in the file? 81 | 82 | """ 83 | with open(path, 'rb') as f: 84 | raw = '' 85 | while True: 86 | try: 87 | line = f.readline().decode() 88 | raw += line 89 | if re.match(patterns.END, line): 90 | break 91 | except UnicodeDecodeError: 92 | raise PVLParseError("Error parsing PVL label from " 93 | "file: {}".format(path)) 94 | return raw 95 | 96 | @staticmethod 97 | def _remove_comments(raw): 98 | return re.sub(patterns.COMMENT, '', raw) 99 | 100 | @staticmethod 101 | def _parse(raw): 102 | raw = LabelParser._remove_comments(raw) 103 | label_iter = re.finditer(patterns.STATEMENT, raw) 104 | return LabelParser._parse_iter(label_iter) 105 | 106 | @staticmethod 107 | def _parse_iter(label_iter): 108 | """Recursively parse a PVL label iterator""" 109 | obj = {} 110 | while True: 111 | try: 112 | # Try to fetch the next match from the iter 113 | match = next(label_iter) 114 | val = match.group('val') 115 | key = match.group('key') 116 | # Handle nested object groups 117 | if key == 'OBJECT': 118 | obj.update({ 119 | val: LabelParser._parse_iter(label_iter) 120 | }) 121 | elif key == 'END_OBJECT': 122 | return obj 123 | # Add key/value pair to dict 124 | else: 125 | # Should this value be a numeric type? 126 | try: 127 | val = LabelParser._convert_to_numeric(val) 128 | except ValueError: 129 | pass 130 | # Should this value have units? 131 | if match.group('units'): 132 | val = Quantity(val, match.group('units')) 133 | # Add it to the dict 134 | obj.update({key: val}) 135 | except StopIteration: 136 | break 137 | return obj 138 | 139 | @staticmethod 140 | def _convert_to_numeric(s): 141 | """Convert a string to its appropriate numeric type""" 142 | if re.match(patterns.INTEGER, s): 143 | return int(s) 144 | elif re.match(patterns.FLOATING, s): 145 | return float(s) 146 | else: 147 | raise ValueError 148 | -------------------------------------------------------------------------------- /dtmimporter/ui/terrainpanel.py: -------------------------------------------------------------------------------- 1 | # This file is a part of the HiRISE DTM Importer for Blender 2 | # 3 | # Copyright (C) 2017 Arizona Board of Regents on behalf of the Planetary Image 4 | # Research Laboratory, Lunar and Planetary Laboratory at the University of 5 | # Arizona. 6 | # 7 | # This program is free software: you can redistribute it and/or modify it 8 | # under the terms of the GNU General Public License as published by the Free 9 | # Software Foundation, either version 3 of the License, or (at your option) 10 | # any later version. 11 | # 12 | # This program is distributed in the hope that it will be useful, but 13 | # WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY 14 | # or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License 15 | # for more details. 16 | # 17 | # You should have received a copy of the GNU General Public License along 18 | # with this program. If not, see . 19 | 20 | """Blender panel for managing a DTM *after* it's been imported""" 21 | 22 | import bpy 23 | from bpy.types import ( 24 | Operator, 25 | Panel, 26 | ) 27 | from bpy.props import FloatProperty 28 | 29 | from ..mesh.terrain import BTerrain 30 | from ..mesh.dtm import DTM 31 | 32 | 33 | class TerrainPanel(Panel): 34 | """Creates a Panel in the Object properites window for terrain objects""" 35 | bl_label = "Terrain Model" 36 | bl_idname = "OBJECT_PT_terrain" 37 | bl_space_type = "PROPERTIES" 38 | bl_region_type = "WINDOW" 39 | bl_context = "object" 40 | 41 | # Allow the user to specify a new resolution factor for reloading the 42 | # terrain data at. This is useful because it allows the user to stage 43 | # a scene with a low resolution terrain map, apply textures, modifiers, 44 | # etc. and then increase the resolution to prepare for final rendering. 45 | # 46 | # Displaying this value as a percentage (0, 100] is an intuitive way 47 | # for users to grasp what this value does. The DTM importer, however, 48 | # wants to recieve a value between (0, 1]. This is obviously a 49 | # straightforward conversion: 50 | # 51 | # f(x) = x / 100 52 | # 53 | # But this conversion should happen here, in the terrain panel, rather 54 | # than in the DTM importing utility itself. We can't pass get/set 55 | # functions to the property itself because they result in a recursion 56 | # error. Instead, we use another, hidden, property to store the scaled 57 | # resolution. 58 | bpy.types.Object.dtm_resolution = FloatProperty( 59 | subtype="PERCENTAGE", 60 | name="New Resolution", 61 | description=( 62 | "Percentage scale for terrain model resolution. 100\% loads the " 63 | "model at full resolution (i.e. one vertex for each post in the " 64 | "original terrain model) and is *MEMORY INTENSIVE*. Downsampling " 65 | "uses Nearest Neighbors. The downsampling algorithm may need to " 66 | "alter the resolution you specify here to ensure it results in a " 67 | "whole number of vertices. If it needs to alter the value you " 68 | "specify, you are guaranteed that it will shrink it (i.e. " 69 | "decrease the DTM resolution." 70 | ), 71 | min=1.0, max=100.0, default=10.0 72 | ) 73 | bpy.types.Object.scaled_dtm_resolution = FloatProperty( 74 | options={'HIDDEN'}, 75 | name="Scaled Terrain Model Resolution", 76 | get=(lambda self: self.dtm_resolution / 100.0) 77 | ) 78 | 79 | @classmethod 80 | def poll(cls, context): 81 | obj = context.active_object 82 | return obj and obj.get("IS_TERRAIN", False) 83 | 84 | def draw(self, context): 85 | obj = context.active_object 86 | layout = self.layout 87 | 88 | # User Controls 89 | layout.prop(obj, 'dtm_resolution') 90 | layout.operator("terrain.reload") 91 | 92 | # Metadata 93 | self.draw_metadata_panel(context) 94 | 95 | def draw_metadata_panel(self, context): 96 | """Display some metadata about the DTM""" 97 | obj = context.active_object 98 | layout = self.layout 99 | 100 | metadata_panel = layout.box() 101 | 102 | dtm_resolution = metadata_panel.row() 103 | dtm_resolution.label(text='Current Resolution: ') 104 | dtm_resolution.label(text='{:9,.2%}'.format( 105 | obj['DTM_RESOLUTION'] 106 | )) 107 | 108 | mesh_scale = metadata_panel.row() 109 | mesh_scale.label(text='Current Scale: ') 110 | mesh_scale.label(text='{:9,.2f} m/post'.format( 111 | obj['MESH_SCALE'] 112 | )) 113 | 114 | dtm_scale = metadata_panel.row() 115 | dtm_scale.label(text='Original Scale: ') 116 | dtm_scale.label(text='{:9,.2f} m/post'.format( 117 | obj['MAP_SCALE'] 118 | )) 119 | 120 | dtm_min_lat = metadata_panel.row() 121 | dtm_min_lat.label(text='Minimum Latitude: ') 122 | dtm_min_lat.label(text='{:9,.3f} lat'.format( 123 | obj['MINIMUM_LATITUDE'] 124 | )) 125 | 126 | dtm_max_lat = metadata_panel.row() 127 | dtm_max_lat.label(text='Maximum Latitude: ') 128 | dtm_max_lat.label(text='{:9,.3f} lat'.format( 129 | obj['MAXIMUM_LATITUDE'] 130 | )) 131 | 132 | dtm_east_lon = metadata_panel.row() 133 | dtm_east_lon.label(text='Easternmost Longitude: ') 134 | dtm_east_lon.label(text='{:9,.3f} lon'.format( 135 | obj['EASTERNMOST_LONGITUDE'] 136 | )) 137 | 138 | dtm_west_lon = metadata_panel.row() 139 | dtm_west_lon.label(text='Westernmost Longitude: ') 140 | dtm_west_lon.label(text='{:9,.3f} lon'.format( 141 | obj['WESTERNMOST_LONGITUDE'] 142 | )) 143 | 144 | return {'FINISHED'} 145 | 146 | 147 | class ReloadTerrain(Operator): 148 | """Button for reloading the terrain mesh at a new resolution.""" 149 | bl_idname = "terrain.reload" 150 | bl_label = "Reload Terrain" 151 | 152 | @classmethod 153 | def poll(cls, context): 154 | obj = context.active_object 155 | return obj and obj.get("IS_TERRAIN", False) 156 | 157 | def execute(self, context): 158 | # Reload the terrain 159 | obj = context.active_object 160 | path = obj['PATH'] 161 | 162 | scaled_dtm_resolution = obj.scaled_dtm_resolution 163 | 164 | # Reload BTerrain with new DTM 165 | dtm = DTM(path, scaled_dtm_resolution) 166 | BTerrain.reload(obj, dtm) 167 | 168 | return {"FINISHED"} 169 | 170 | -------------------------------------------------------------------------------- /dtmimporter/ui/importer.py: -------------------------------------------------------------------------------- 1 | # This file is a part of the HiRISE DTM Importer for Blender 2 | # 3 | # Copyright (C) 2017 Arizona Board of Regents on behalf of the Planetary Image 4 | # Research Laboratory, Lunar and Planetary Laboratory at the University of 5 | # Arizona. 6 | # 7 | # This program is free software: you can redistribute it and/or modify it 8 | # under the terms of the GNU General Public License as published by the Free 9 | # Software Foundation, either version 3 of the License, or (at your option) 10 | # any later version. 11 | # 12 | # This program is distributed in the hope that it will be useful, but 13 | # WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY 14 | # or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License 15 | # for more details. 16 | # 17 | # You should have received a copy of the GNU General Public License along 18 | # with this program. If not, see . 19 | 20 | """Blender menu importer for loading a DTM""" 21 | 22 | import bpy 23 | import bpy.props 24 | from bpy.props import ( 25 | BoolProperty, 26 | FloatProperty, 27 | StringProperty, 28 | ) 29 | from bpy_extras.io_utils import ImportHelper 30 | 31 | from ..mesh.terrain import BTerrain 32 | from ..mesh.dtm import DTM 33 | 34 | 35 | class ImportHiRISETerrain(bpy.types.Operator, ImportHelper): 36 | """DTM Import Helper""" 37 | bl_idname = "import_mesh.pds_dtm" 38 | bl_label = "Import HiRISE Terrain Model" 39 | bl_options = {'UNDO'} 40 | filename_ext = ".img" 41 | filter_glob: StringProperty( 42 | options={'HIDDEN'}, 43 | default="*.img" 44 | ) 45 | 46 | # Allow the user to specify a resolution factor for loading the 47 | # terrain data at. This is useful because it allows the user to stage 48 | # a scene with a low resolution terrain map, apply textures, modifiers, 49 | # etc. and then increase the resolution to prepare for final rendering. 50 | # 51 | # Displaying this value as a percentage (0, 100] is an intuitive way 52 | # for users to grasp what this value does. The DTM importer, however, 53 | # wants to recieve a value between (0, 1]. This is obviously a 54 | # straightforward conversion: 55 | # 56 | # f(x) = x / 100 57 | # 58 | # But this conversion should happen here, in the terrain panel, rather 59 | # than in the DTM importing utility itself. We can't pass get/set 60 | # functions to the property itself because they result in a recursion 61 | # error. Instead, we use another, hidden, property to store the scaled 62 | # resolution. 63 | dtm_resolution: FloatProperty( 64 | subtype="PERCENTAGE", 65 | description=( 66 | "Percentage scale for terrain model resolution. 100\% loads the " 67 | "model at full resolution (i.e. one vertex for each post in the " 68 | "original terrain model) and is *MEMORY INTENSIVE*. Downsampling " 69 | "uses Nearest Neighbors. You will be able to increase the " 70 | "resolution of your mesh later, and still maintain all textures, " 71 | "transformations, modifiers, etc., so best practice is to start " 72 | "small. The downsampling algorithm may need to alter the " 73 | "resolution you specify here to ensure it results in a whole " 74 | "number of vertices. If it needs to alter the value you specify, " 75 | "you are guaranteed that it will shrink it (i.e. decrease the " 76 | "DTM resolution." 77 | ), 78 | name="Terrain Model Resolution", 79 | min=1.0, max=100.0, default=10.0 80 | ) 81 | scaled_dtm_resolution: FloatProperty( 82 | options={'HIDDEN'}, 83 | name="Scaled Terrain Model Resolution", 84 | get=(lambda self: self.dtm_resolution / 100) 85 | ) 86 | 87 | # HiRISE DTMs are huge, but it can be nice to load them in at scale. Here, 88 | # we present the user with the option of setting up the Blender viewport 89 | # to avoid a couple of common pitfalls encountered when working with such 90 | # a large mesh. 91 | # 92 | # 1. The Blender viewport has a default clipping distance of 1km. HiRISE 93 | # DTMs are often many kilometers in each direction. If this setting is 94 | # not changed, an unsuspecting user may only see part (or even nothing 95 | # at all) of the terrain. This option (true, by default) instructs 96 | # Blender to change the clipping distance to something appropriate for 97 | # the DTM, and scales the grid floor to have gridlines 1km apart, 98 | # instead of 1m apart. 99 | should_setup_viewport: BoolProperty( 100 | description=( 101 | "Set up the Blender screen to try and avoid clipping the DTM " 102 | "and to make the grid floor larger. *WARNING* This will change " 103 | "clipping distances and the Blender grid floor, and will fit the " 104 | "DTM in the viewport." 105 | ), 106 | name="Setup Blender Scene", default=True 107 | ) 108 | # 2. Blender's default units are dimensionless. This option instructs 109 | # Blender to change its unit's dimension to meters. 110 | should_setup_units: BoolProperty( 111 | description=( 112 | "Set the Blender scene to use meters as its unit." 113 | ), 114 | name="Set Blender Units to Meters", default=True 115 | ) 116 | 117 | def execute(self, context): 118 | """Runs when the "Import HiRISE Terrain Model" button is pressed""" 119 | filepath = bpy.path.ensure_ext(self.filepath, self.filename_ext) 120 | # Create a BTerrain from the DTM 121 | dtm = DTM(filepath, self.scaled_dtm_resolution) 122 | BTerrain.new(dtm) 123 | 124 | # Set up the Blender UI 125 | if self.should_setup_units: 126 | self._setup_units(context) 127 | if self.should_setup_viewport: 128 | self._setup_viewport(context) 129 | 130 | return {"FINISHED"} 131 | 132 | def _setup_units(self, context): 133 | """Sets up the Blender scene for viewing the DTM""" 134 | scene = bpy.context.scene 135 | 136 | # Set correct units 137 | scene.unit_settings.system = 'METRIC' 138 | scene.unit_settings.scale_length = 1.0 139 | 140 | return {'FINISHED'} 141 | 142 | def _setup_viewport(self, context): 143 | """Sets up the Blender screen to make viewing the DTM easier""" 144 | screen = bpy.context.screen 145 | ''' 146 | # Fetch the 3D_VIEW Area 147 | for area in screen.areas: 148 | if area.type == 'VIEW_3D': 149 | space = area.spaces[0] 150 | # Adjust 3D View Properties 151 | # TODO: Can these be populated more intelligently? 152 | space.clip_end = 100000 153 | space.grid_scale = 1000 154 | space.grid_lines = 50 155 | ''' 156 | # Fly to a nice view of the DTM 157 | self._view_dtm(context) 158 | 159 | return {'FINISHED'} 160 | 161 | def _view_dtm(self, context): 162 | """Sets up the Blender screen to make viewing the DTM easier""" 163 | screen = bpy.context.screen 164 | 165 | # Fetch the 3D_VIEW Area 166 | for area in screen.areas: 167 | if area.type == 'VIEW_3D': 168 | # Move the camera around in the viewport. This requires 169 | # a context override. 170 | for region in area.regions: 171 | if region.type == 'WINDOW': 172 | override = { 173 | 'area': area, 174 | 'region': region, 175 | 'edit_object': bpy.context.edit_object 176 | } 177 | # Center View on DTM (SHORTCUT: '.') 178 | bpy.ops.view3d.view_selected(override) 179 | # Move to 'TOP' viewport (SHORTCUT: NUMPAD7) 180 | #bpy.ops.view3d.viewnumpad(override, type='TOP') 181 | 182 | return {'FINISHED'} 183 | -------------------------------------------------------------------------------- /dtmimporter/mesh/triangulate.py: -------------------------------------------------------------------------------- 1 | # This file is a part of the HiRISE DTM Importer for Blender 2 | # 3 | # Copyright (C) 2017 Arizona Board of Regents on behalf of the Planetary Image 4 | # Research Laboratory, Lunar and Planetary Laboratory at the University of 5 | # Arizona. 6 | # 7 | # This program is free software: you can redistribute it and/or modify it 8 | # under the terms of the GNU General Public License as published by the Free 9 | # Software Foundation, either version 3 of the License, or (at your option) 10 | # any later version. 11 | # 12 | # This program is distributed in the hope that it will be useful, but 13 | # WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY 14 | # or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License 15 | # for more details. 16 | # 17 | # You should have received a copy of the GNU General Public License along 18 | # with this program. If not, see . 19 | 20 | """Triangulation algorithms""" 21 | 22 | import numpy as np 23 | 24 | 25 | class Triangulate: 26 | """ 27 | A triangulation algorithm for creating a mesh from a DTM raster. 28 | 29 | I have been re-writing parts of the Blender HiRISE DTM importer in an 30 | effort to cull its dependencies on external packages. Originally, the 31 | add-on relied on SciPy's Delaunay triangulation (really a wrapper for 32 | Qhull's Delaunay triangulation) to triangulate a mesh from a HiRISE DTM. 33 | 34 | This re-write is much better suited to the problem domain. The SciPy 35 | Delaunay triangulation creates a mesh from any arbitrary point cloud and, 36 | while robust, doesn't care about the fact that our HiRISE DTMs are 37 | regularly gridded rasters. This triangulation algorithm is less robust 38 | but much faster. Credit is due to Tim Spriggs for his work on the previous 39 | Blender HiRISE DTM importer --- this triangulation algorithm largely 40 | models the one in his add-on with a few changes (namely interfacing 41 | with NumPy's API). 42 | 43 | Overview 44 | ---------- 45 | Suppose we have a DTM: 46 | 47 | .. code:: 48 | 49 | - - - - - - - - X X - - - - - 50 | - - - - - - X X X X X - - - - 51 | - - - - X X X X X X X X - - - 52 | - - X X X X X X X X X X X - - 53 | X X X X X X X X X X X X X X - 54 | - X X X X X X X X X X X X X X 55 | - - X X X X X X X X X X X - - 56 | - - - X X X X X X X X - - - - 57 | - - - - X X X X X - - - - - - 58 | - - - - - X X - - - - - - - - 59 | 60 | where 'X' represents valid values and '-' represents invalid values. 61 | Valid values should become vertices in the resulting mesh, invalid 62 | values should be ignored. 63 | 64 | Our end goal is to supply Blender with: 65 | 66 | 1. an (n x 3) list of vertices 67 | 68 | 2. an (m x 3) list of faces. 69 | 70 | A vertex is a 3-tuple that we get from the DTM raster array. The 71 | z-coordinate is whatever elevation value is in the DTM and the xy- 72 | coordinates are the image indices multiplied by the resolution of the 73 | DTM (e.g. if the DTM is at 5m/px, the first vertex is at (0m, 0m, 74 | z_00) and the vertex to the right of it is at (5m, 0m, z_01)). 75 | 76 | A face is a 3-tuple (because we're using triangles) where each element 77 | is an index of a vertex in the vertices list. Computing the faces is 78 | tricky because we want to leverage the orthogonal structure of the DTM 79 | raster for computational efficiency but we also need to reference 80 | vertex indices in our faces, which don't observe any regular 81 | structure. 82 | 83 | We take two rows at a time from the DTM raster and track the *raster 84 | row* indices as well as well as the *vertex* indices. Raster row 85 | indices are the distance of a pixel in the raster from the left-most 86 | (valid *or* invalid) pixel of the row. The first vertex is index 0 and 87 | corresponds to the upperleft-most valid pixel in the DTM raster. 88 | Vertex indices increase to the right and then down. 89 | 90 | For example, the first two rows: 91 | 92 | .. code:: 93 | 94 | - - - - - - - - X X - - - - - 95 | - - - - - - X X X X X - - - - 96 | 97 | in vertex indices: 98 | 99 | .. code:: 100 | 101 | - - - - - - - - 0 1 - - - - - 102 | - - - - - - 2 3 4 5 6 - - - - 103 | 104 | and in raster row indices: 105 | 106 | .. code:: 107 | 108 | - - - - - - - - 9 10 - - - - - 109 | - - - - - - 7 8 9 10 11 - - - - 110 | 111 | To simplify, we will only add valid square regions to our mesh. So, 112 | for these first two rows the only region that will be added to our 113 | mesh is the quadrilateral formed by vertices 0, 1, 4 and 5. We 114 | further divide this area into 2 triangles and add the vertices to the 115 | face list in CCW order (i.e. t1: (4, 1, 0), t2: (4, 5, 1)). 116 | 117 | After the triangulation between two rows is completed, the bottom 118 | row is cached as the top row and the next row in the DTM raster is 119 | read as the new bottom row. This process continues until the entire 120 | raster has been triangulated. 121 | 122 | Todo 123 | --------- 124 | * It should be pretty trivial to add support for triangular 125 | regions (i.e. in the example above, also adding the triangles 126 | formed by (3, 4, 0) and (5, 6, 1)). 127 | 128 | """ 129 | def __init__(self, array): 130 | self.array = array 131 | self.faces = self._triangulate() 132 | 133 | def _triangulate(self): 134 | """Triangulate a mesh from a topography array.""" 135 | # Allocate memory for the triangles array 136 | max_tris = (self.array.shape[0] - 1) * (self.array.shape[1] - 1) * 2 137 | tris = np.zeros((max_tris, 3), dtype=int) 138 | ntri = 0 139 | 140 | # We initialize a vertex counter at 0 141 | prev_vtx_start = 0 142 | # We don't care about the values in the array, just whether or not 143 | # they are valid. 144 | prev = ~np.isnan(self.array[0]) 145 | # We can sum this boolean array to count the number of valid entries 146 | prev_num_valid = prev.sum() 147 | # TODO: Probably a more clear (and faster) function than argmax for 148 | # getting the first Truth-y value in a 1d array. 149 | prev_img_start = np.argmax(prev) 150 | 151 | # Start quadrangulation 152 | for i in range(1, self.array.shape[0]): 153 | # Fetch this row, get our bearings in image *and* vertex space 154 | curr = ~np.isnan(self.array[i]) 155 | curr_vtx_start = prev_vtx_start + prev_num_valid 156 | curr_img_start = np.argmax(curr) 157 | curr_num_valid = curr.sum() 158 | # Find the overlap between this row and the previous one 159 | overlap = np.logical_and(prev, curr) 160 | num_tris = overlap.sum() - 1 161 | overlap_start = np.argmax(overlap) 162 | # Store triangles 163 | for j in range(num_tris): 164 | curr_pad = overlap_start - curr_img_start + j 165 | prev_pad = overlap_start - prev_img_start + j 166 | tris[ntri + 0] = [ 167 | curr_vtx_start + curr_pad, 168 | prev_vtx_start + prev_pad + 1, 169 | prev_vtx_start + prev_pad 170 | ] 171 | tris[ntri + 1] = [ 172 | curr_vtx_start + curr_pad, 173 | curr_vtx_start + curr_pad + 1, 174 | prev_vtx_start + prev_pad + 1 175 | ] 176 | ntri += 2 177 | # Cache current row as previous row 178 | prev = curr 179 | prev_vtx_start = curr_vtx_start 180 | prev_img_start = curr_img_start 181 | prev_num_valid = curr_num_valid 182 | 183 | return tris[:ntri] 184 | 185 | def face_list(self): 186 | return list(self.faces) 187 | -------------------------------------------------------------------------------- /dtmimporter/mesh/dtm.py: -------------------------------------------------------------------------------- 1 | # This file is a part of the HiRISE DTM Importer for Blender 2 | # 3 | # Copyright (C) 2017 Arizona Board of Regents on behalf of the Planetary Image 4 | # Research Laboratory, Lunar and Planetary Laboratory at the University of 5 | # Arizona. 6 | # 7 | # This program is free software: you can redistribute it and/or modify it 8 | # under the terms of the GNU General Public License as published by the Free 9 | # Software Foundation, either version 3 of the License, or (at your option) 10 | # any later version. 11 | # 12 | # This program is distributed in the hope that it will be useful, but 13 | # WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY 14 | # or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License 15 | # for more details. 16 | # 17 | # You should have received a copy of the GNU General Public License along 18 | # with this program. If not, see . 19 | 20 | """Objects for importing HiRISE DTMs.""" 21 | 22 | import numpy as np 23 | 24 | from .. import pvl 25 | 26 | 27 | class DTM: 28 | """ 29 | HiRISE Digital Terrain Model 30 | 31 | This class imports a HiRISE DTM from a Planetary Data Systems (PDS) 32 | compliant .IMG file. 33 | 34 | Parameters 35 | ---------- 36 | path : str 37 | terrain_resolution : float, optional 38 | Controls the resolution the DTM is read at. This should be a float 39 | in the range [0.01, 1.0] (and will be constrained to this range). A 40 | value of 1.0 will result in the DTM being read at full resolution. A 41 | value of 0.01 will result in the DTM being read at 1/100th resolution. 42 | Default is 1.0 (no downsampling). 43 | 44 | Todo 45 | ---- 46 | * Use GDAL for importing the DTM if it is installed for this Python 47 | environment. If/when I have the time to do this, it probably 48 | warrants breaking out separate importer classes. The benefits of 49 | doing this are pretty substantial, though: 50 | 51 | + More reliable (doesn't rely on my PVL parser for finding the 52 | valid values in the DTM, for locating the starting position of 53 | the elevation data in the .IMG file) 54 | 55 | + Other, better, downsampling algorithms are already built in. 56 | 57 | + Would make this much better at general PDS DTM importing, 58 | currently some of the import code is specific to HiRISE DTMs. 59 | 60 | """ 61 | 62 | # Special constants in our data: 63 | # NULL : No data at this point. 64 | # LRS : Low Representation Saturation 65 | # LIS : Low Instrument Saturation 66 | # HRS : High Representation Saturation 67 | # HIS : High Insturment Saturation 68 | SPECIAL_VALUES = { 69 | "NULL": np.fromstring(b'\xFF\x7F\xFF\xFB', dtype='>f4')[0], 70 | "LRS": np.fromstring(b'\xFF\x7F\xFF\xFC', dtype='>f4')[0], 71 | "LIS": np.fromstring(b'\xFF\x7F\xFF\xFD', dtype='>f4')[0], 72 | "HRS": np.fromstring(b'\xFF\x7F\xFF\xFE', dtype='>f4')[0], 73 | "HIS": np.fromstring(b'\xFF\x7F\xFF\xFF', dtype='>f4')[0] 74 | } 75 | 76 | def __init__(self, path, terrain_resolution=1.0): 77 | self.path = path 78 | self.terrain_resolution = terrain_resolution 79 | self.label = self._read_label() 80 | self.data = self._read_data() 81 | 82 | def _read_label(self): 83 | """Returns a dict-like representation of a PVL label""" 84 | return pvl.load(self.path) 85 | 86 | def _read_data(self): 87 | """ 88 | Reads elevation data from a PDS .IMG file. 89 | 90 | Notes 91 | ----- 92 | * Uses nearest-neighbor to downsample data. 93 | 94 | Todo 95 | ---- 96 | * Add other downsampling algorithms. 97 | 98 | """ 99 | h, w = self.image_resolution 100 | max_samples = int(w - w % self.bin_size) 101 | 102 | data = np.zeros(self.shape) 103 | with open(self.path, 'rb') as f: 104 | # Seek to the first byte of data 105 | start_byte = self._get_data_start() 106 | f.seek(start_byte) 107 | # Iterate over each row of the data 108 | for r in range(data.shape[0]): 109 | # Each iteration, seek to the right location before 110 | # reading a row. We determine this location as the 111 | # first byte of data PLUS a offset which we calculate as the 112 | # product of: 113 | # 114 | # 4, the number of bytes in a single record 115 | # r, the current row index 116 | # w, the number of records in a row of the DTM 117 | # bin_size, the number of records in a bin 118 | # 119 | # This is where we account for skipping over rows. 120 | offset = int(4 * r * w * self.bin_size) 121 | f.seek(start_byte + offset) 122 | # Read a row 123 | row = np.fromfile(f, dtype=np.float32, count=max_samples) 124 | # This is where we account for skipping over columns. 125 | data[r] = row[::self.bin_size] 126 | 127 | data = self._process_invalid_data(data) 128 | return data 129 | 130 | def _get_data_start(self): 131 | """Gets the start position of the DTM data block""" 132 | label_length = self.label['RECORD_BYTES'] 133 | num_labels = self.label.get('LABEL_RECORDS', 1) 134 | return int(label_length * num_labels) 135 | 136 | def _process_invalid_data(self, data): 137 | """Sets any 'NULL' elevation values to np.NaN""" 138 | invalid_data_mask = (data <= self.SPECIAL_VALUES['NULL']) 139 | data[invalid_data_mask] = np.NaN 140 | return data 141 | 142 | @property 143 | def map_size(self): 144 | """Geographic size of the bounding box around the DTM""" 145 | scale = self.map_scale * self.unit_scale 146 | w = self.image_resolution[0] * scale 147 | h = self.image_resolution[1] * scale 148 | return (w, h) 149 | 150 | @property 151 | def mesh_scale(self): 152 | """Geographic spacing between mesh vertices""" 153 | return self.bin_size * self.map_scale * self.unit_scale 154 | 155 | @property 156 | def map_info(self): 157 | """Map Projection metadata""" 158 | return self.label['IMAGE_MAP_PROJECTION'] 159 | 160 | @property 161 | def map_scale(self): 162 | """Geographic spacing between DTM posts""" 163 | map_scale = self.map_info.get('MAP_SCALE', None) 164 | return getattr(map_scale, 'value', 1.0) 165 | 166 | @property 167 | def map_units(self): 168 | """Geographic unit for spacing between DTM posts""" 169 | map_scale = self.map_info.get('MAP_SCALE', None) 170 | return getattr(map_scale, 'units', None) 171 | 172 | @property 173 | def min_lat(self): 174 | """MINIMUM_LATITUDE of DTM""" 175 | min_lat = self.map_info.get('MINIMUM_LATITUDE', None) 176 | return getattr(min_lat, 'value', 1.0) 177 | 178 | @property 179 | def max_lat(self): 180 | """MAXIMUM_LATITUDE of DTM""" 181 | max_lat = self.map_info.get('MAXIMUM_LATITUDE', None) 182 | return getattr(max_lat, 'value', 1.0) 183 | 184 | @property 185 | def eastern_lon(self): 186 | """EASTERNMOST_LONGITUDE of DTM""" 187 | eastern_lon = self.map_info.get('EASTERNMOST_LONGITUDE', None) 188 | return getattr(eastern_lon, 'value', 1.0) 189 | 190 | @property 191 | def western_lon(self): 192 | """WESTERNMOST_LONGITUDE of DTM""" 193 | western_lon = self.map_info.get('WESTERNMOST_LONGITUDE', None) 194 | return getattr(western_lon, 'value', 1.0) 195 | 196 | @property 197 | def unit_scale(self): 198 | """ 199 | The function that creates a Blender mesh from this object will assume 200 | that the height values passed into it are in meters --- this 201 | property is a multiplier to convert DTM-units to meters. 202 | """ 203 | scaling_factors = { 204 | 'KM/PIXEL': 1000, 205 | 'METERS/PIXEL': 1 206 | } 207 | return scaling_factors.get(self.map_units, 1.0) 208 | 209 | @property 210 | def terrain_resolution(self): 211 | """Vertex spacing, meters""" 212 | return self._terrain_resolution 213 | 214 | @terrain_resolution.setter 215 | def terrain_resolution(self, t): 216 | self._terrain_resolution = np.clip(t, 0.01, 1.0) 217 | 218 | @property 219 | def bin_size(self): 220 | """The width of the (square) downsampling bin""" 221 | return int(np.ceil(1 / self.terrain_resolution)) 222 | 223 | @property 224 | def image_stats(self): 225 | """Image statistics from the original DTM label""" 226 | return self.label['IMAGE'] 227 | 228 | @property 229 | def image_resolution(self): 230 | """(Line, Sample) resolution of the original DTM""" 231 | return (self.image_stats['LINES'], self.image_stats['LINE_SAMPLES']) 232 | 233 | @property 234 | def size(self): 235 | """Number of posts in our reduced DTM""" 236 | return self.shape[0] * self.shape[1] 237 | 238 | @property 239 | def shape(self): 240 | """Shape of our reduced DTM""" 241 | num_rows = self.image_resolution[0] // self.bin_size 242 | num_cols = self.image_resolution[1] // self.bin_size 243 | return (num_rows, num_cols) 244 | -------------------------------------------------------------------------------- /dtmimporter/mesh/terrain.py: -------------------------------------------------------------------------------- 1 | # This file is a part of the HiRISE DTM Importer for Blender 2 | # 3 | # Copyright (C) 2017 Arizona Board of Regents on behalf of the Planetary Image 4 | # Research Laboratory, Lunar and Planetary Laboratory at the University of 5 | # Arizona. 6 | # 7 | # This program is free software: you can redistribute it and/or modify it 8 | # under the terms of the GNU General Public License as published by the Free 9 | # Software Foundation, either version 3 of the License, or (at your option) 10 | # any later version. 11 | # 12 | # This program is distributed in the hope that it will be useful, but 13 | # WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY 14 | # or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License 15 | # for more details. 16 | # 17 | # You should have received a copy of the GNU General Public License along 18 | # with this program. If not, see . 19 | 20 | """Objects for creating 3D models in Blender""" 21 | 22 | import bpy 23 | import bmesh 24 | 25 | import numpy as np 26 | 27 | from .triangulate import Triangulate 28 | 29 | 30 | class BTerrain: 31 | """ 32 | Functions for creating Blender meshes from DTM objects 33 | 34 | This class contains functions that convert DTM objects to Blender meshes. 35 | Its main responsiblity is to triangulate a mesh from the elevation data in 36 | the DTM. Additionally, it attaches some metadata to the object and creates 37 | a UV map for it so that companion ortho-images drape properly. 38 | 39 | This class provides two public methods: `new()` and `reload()`. 40 | 41 | `new()` creates a new object[1] and attaches a new mesh to it. 42 | 43 | `reload()` replaces the mesh that is attached to an already existing 44 | object. This allows us to retain the location and orientation of the parent 45 | object's coordinate system but to reload the terrain at a different 46 | resolution. 47 | 48 | Notes 49 | ---------- 50 | [1] If you're unfamiliar with Blender, one thing that will help you in 51 | reading this code is knowing the difference between 'meshes' and 52 | 'objects'. A mesh is just a collection of vertices, edges and 53 | faces. An object may have a mesh as a child data object and 54 | contains additional information, e.g. the location and orientation 55 | of the coordinate system its child-meshes are reckoned in terms of. 56 | 57 | """ 58 | 59 | @staticmethod 60 | def new(dtm, name='Terrain'): 61 | """ 62 | Loads a new terrain 63 | 64 | Parameters 65 | ---------- 66 | dtm : DTM 67 | name : str, optional 68 | The name that will be assigned to the new object, defaults 69 | to 'Terrain' (and, if an object named 'Terrain' already 70 | exists, Blender will automatically extend the name of the 71 | new object to something like 'Terrain.001') 72 | 73 | Returns 74 | ---------- 75 | obj : bpy_types.Object 76 | 77 | """ 78 | bpy.ops.object.add(type="MESH") 79 | obj = bpy.context.object 80 | obj.name = name 81 | 82 | # Fill the object data with a Terrain mesh 83 | obj.data = BTerrain._mesh_from_dtm(dtm) 84 | 85 | # Add some meta-information to the object 86 | metadata = BTerrain._create_metadata(dtm) 87 | BTerrain._setobjattrs(obj, **metadata) 88 | 89 | # Center the mesh to its origin and create a UV map for draping 90 | # ortho images. 91 | BTerrain._center(obj) 92 | 93 | return obj 94 | 95 | @staticmethod 96 | def reload(obj, dtm): 97 | """ 98 | Replaces an exisiting object's terrain mesh 99 | 100 | This replaces an object's mesh with a new mesh, transferring old 101 | materials over to the new mesh. This is useful for reloading DTMs 102 | at different resolutions but maintaining textures/location/rotation. 103 | 104 | Parameters 105 | ----------- 106 | obj : bpy_types.Object 107 | An already existing Blender object 108 | dtm : DTM 109 | 110 | Returns 111 | ---------- 112 | obj : bpy_types.Object 113 | 114 | """ 115 | old_mesh = obj.data 116 | new_mesh = BTerrain._mesh_from_dtm(dtm) 117 | 118 | # Copy any old materials to the new mesh 119 | for mat in old_mesh.materials: 120 | new_mesh.materials.append(mat.copy()) 121 | 122 | # Swap out the old mesh for the new one 123 | obj.data = new_mesh 124 | 125 | # Update out-dated meta-information 126 | metadata = BTerrain._create_metadata(dtm) 127 | BTerrain._setobjattrs(obj, **metadata) 128 | 129 | # Center the mesh to its origin and create a UV map for draping 130 | # ortho images. 131 | BTerrain._center(obj) 132 | 133 | return obj 134 | 135 | @staticmethod 136 | def _mesh_from_dtm(dtm, name='Terrain'): 137 | """ 138 | Creates a Blender *mesh* from a DTM 139 | 140 | Parameters 141 | ---------- 142 | dtm : DTM 143 | name : str, optional 144 | The name that will be assigned to the new mesh, defaults 145 | to 'Terrain' (and, if an object named 'Terrain' already 146 | exists, Blender will automatically extend the name of the 147 | new object to something like 'Terrain.001') 148 | 149 | Returns 150 | ---------- 151 | mesh : bpy_types.Mesh 152 | 153 | Notes 154 | ---------- 155 | * We are switching coordinate systems from the NumPy to Blender. 156 | 157 | Numpy: Blender: 158 | + ----> (0, j) ^ (0, y) 159 | | | 160 | | | 161 | v (i, 0) + ----> (x, 0) 162 | 163 | """ 164 | # Create an empty mesh 165 | mesh = bpy.data.meshes.new(name) 166 | 167 | # Get the xy-coordinates from the DTM, see docstring notes 168 | y, x = np.indices(dtm.data.shape).astype('float64') 169 | x *= dtm.mesh_scale 170 | y *= -1 * dtm.mesh_scale 171 | 172 | # Create an array of 3D vertices 173 | vertices = np.dstack([x, y, dtm.data]).reshape((-1, 3)) 174 | 175 | # Drop vertices with NaN values (used in the DTM to represent 176 | # areas with no data) 177 | vertices = vertices[~np.isnan(vertices).any(axis=1)] 178 | 179 | 180 | # Calculate the faces of the mesh 181 | triangulation = Triangulate(dtm.data) 182 | faces = triangulation.face_list() 183 | 184 | # Fill the mesh 185 | mesh.from_pydata(vertices, [], faces) 186 | mesh.update() 187 | 188 | ''' 189 | # Smooth the mesh 190 | for f in mesh.polygons: 191 | f.use_smooth = True 192 | ''' 193 | 194 | # Create a new UV layer 195 | mesh.uv_layers.new(name="HiRISE Generated UV Map") 196 | 197 | # We'll use a bmesh to populate the UV map with values 198 | bm = bmesh.new() 199 | bm.from_mesh(mesh) 200 | bm.faces.ensure_lookup_table() 201 | uv_layer = bm.loops.layers.uv[0] 202 | 203 | # Iterate over each face in the bmesh 204 | num_faces = len(bm.faces) 205 | w = dtm.data.shape[1] 206 | h = dtm.data.shape[0] 207 | for face_index in range(num_faces): 208 | # Iterate over each loop in the face 209 | for loop in bm.faces[face_index].loops: 210 | # Get this loop's vertex coordinates 211 | vert_coords = loop.vert.co.xy 212 | # And calculate it's uv coordinate. We do this by dividing the 213 | # vertice's x and y coordinates by: 214 | # 215 | # d + 1, dimensions of DTM (in "posts") 216 | # mesh_scale, meters/DTM "post" 217 | # 218 | # This has the effect of mapping the vertex to its 219 | # corresponding "post" index in the DTM, and then mapping 220 | # that value to the range [0, 1). 221 | u = vert_coords.x / ((w + 1) * dtm.mesh_scale) 222 | v = 1 + vert_coords.y / ((h + 1) * dtm.mesh_scale) 223 | loop[uv_layer].uv = (u, v) 224 | 225 | bm.to_mesh(mesh) 226 | return mesh 227 | 228 | @staticmethod 229 | def _center(obj): 230 | """Move object geometry to object origin""" 231 | bpy.context.view_layer.objects.active = obj 232 | bpy.ops.object.origin_set(center='BOUNDS') 233 | 234 | @staticmethod 235 | def _setobjattrs(obj, **attrs): 236 | for key, value in attrs.items(): 237 | obj[key] = value 238 | 239 | @staticmethod 240 | def _create_metadata(dtm): 241 | """Returns a dict containing meta-information about a DTM""" 242 | return { 243 | 'PATH': dtm.path, 244 | 'MESH_SCALE': dtm.mesh_scale, 245 | 'DTM_RESOLUTION': dtm.terrain_resolution, 246 | 'BIN_SIZE': dtm.bin_size, 247 | 'MAP_SIZE': dtm.map_size, 248 | 'MAP_SCALE': dtm.map_scale * dtm.unit_scale, 249 | 'UNIT_SCALE': dtm.unit_scale, 250 | 'MINIMUM_LATITUDE': dtm.min_lat, 251 | 'MAXIMUM_LATITUDE': dtm.max_lat, 252 | 'EASTERNMOST_LONGITUDE': dtm.eastern_lon, 253 | 'WESTERNMOST_LONGITUDE': dtm.western_lon, 254 | 'IS_TERRAIN': True, 255 | 'HAS_UV_MAP': True 256 | } 257 | --------------------------------------------------------------------------------