├── 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 |
--------------------------------------------------------------------------------