Welcome to mirror list, hosted at ThFree Co, Russian Federation.

git.blender.org/blender-addons.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authormeta-androcto <meta.androcto1@gmail.com>2017-06-15 15:13:42 +0300
committermeta-androcto <meta.androcto1@gmail.com>2017-06-15 15:13:42 +0300
commit722e9d54282befe9eac75b0cd6879a336fe0e394 (patch)
treeca89d1e990a3b3c930a37dc816b922cc8d75de8e
parentc6676127556e5756e4c94f39784d27149f2eb86d (diff)
io_convert_image_to_mesh_img: complete rewrite: T51754
-rw-r--r--io_convert_image_to_mesh_img/__init__.py147
-rw-r--r--io_convert_image_to_mesh_img/import_img.py713
-rw-r--r--io_convert_image_to_mesh_img/mesh/__init__.py25
-rw-r--r--io_convert_image_to_mesh_img/mesh/dtm.py219
-rw-r--r--io_convert_image_to_mesh_img/mesh/terrain.py245
-rw-r--r--io_convert_image_to_mesh_img/mesh/triangulate.py186
-rw-r--r--io_convert_image_to_mesh_img/pvl/__init__.py27
-rw-r--r--io_convert_image_to_mesh_img/pvl/label.py24
-rw-r--r--io_convert_image_to_mesh_img/pvl/parse.py147
-rw-r--r--io_convert_image_to_mesh_img/pvl/patterns.py59
-rw-r--r--io_convert_image_to_mesh_img/ui/__init__.py25
-rw-r--r--io_convert_image_to_mesh_img/ui/importer.py177
-rw-r--r--io_convert_image_to_mesh_img/ui/terrainpanel.py133
13 files changed, 1308 insertions, 819 deletions
diff --git a/io_convert_image_to_mesh_img/__init__.py b/io_convert_image_to_mesh_img/__init__.py
index 35b60997..54213465 100644
--- a/io_convert_image_to_mesh_img/__init__.py
+++ b/io_convert_image_to_mesh_img/__init__.py
@@ -1,130 +1,65 @@
-# ##### BEGIN GPL LICENSE BLOCK #####
+# This file is a part of the HiRISE DTM Importer for Blender
#
-# This program is free software; you can redistribute it and/or
-# modify it under the terms of the GNU General Public License
-# as published by the Free Software Foundation; either version 2
-# of the License, or (at your option) any later version.
+# Copyright (C) 2017 Arizona Board of Regents on behalf of the Planetary Image
+# Research Laboratory, Lunar and Planetary Laboratory at the University of
+# Arizona.
#
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-# GNU General Public License for more details.
+# This program is free software: you can redistribute it and/or modify it
+# under the terms of the GNU General Public License as published by the Free
+# Software Foundation, either version 3 of the License, or (at your option)
+# any later version.
#
-# You should have received a copy of the GNU General Public License
-# along with this program; if not, write to the Free Software Foundation,
-# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+# This program is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
+# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+# for more details.
#
-# ##### END GPL LICENSE BLOCK #####
+# You should have received a copy of the GNU General Public License along
+# with this program. If not, see <http://www.gnu.org/licenses/>.
+
+"""A HiRISE DTM Importer for Blender"""
+
+import bpy
+
+from .ui import importer
+from .ui import terrainpanel
bl_info = {
- "name": "HiRISE DTM from PDS IMG",
- "author": "Tim Spriggs (tims@uahirise.org)",
- "version": (0, 1, 4),
- "blender": (2, 63, 0),
- "location": "File > Import > HiRISE DTM from PDS IMG (.IMG)",
- "description": "Import a HiRISE DTM formatted as a PDS IMG file",
+ "name": "HiRISE DTM Importer",
+ "author": "Nicholas Wolf (nicwolf@pirl.lpl.arizona.edu)",
+ "version": (0, 2, 1),
+ "blender": (2, 78, 0),
+ "license": "GPL",
+ "location": "File > Import > HiRISE DTM (.img)",
+ "description": "Import a HiRISE DTM as a mesh.",
"warning": "May consume a lot of memory",
- "wiki_url": "http://wiki.blender.org/index.php/Extensions:2.6/Py/"
- "Scripts/Import-Export/HiRISE_DTM_from_PDS_IMG",
"category": "Import-Export",
+ "wiki_url": "", # TBD
+ "tracker_url": "", # TBD
+ "link": "", # TBD
+ "support": "TESTING",
}
-
-# Revision History:
-# 0.1.1 - make default import 12x12 bin (fast) to not consume too much memory
-# by default (TJS - 2010-12-07)
-# 0.1.2 - included into svn under the tree:
-# trunk/py/scripts/addons/io_convert_image_to_mesh_img
-# may be moved out to contrib once the blender downloader works well
-# (TJS - 2010-12-14)
-# 0.1.3 - upstream blender updates
-# performance enhancements by Chris Van Horne
-# (TJS - 2012-03-14)
-# 0.1.4 - use bmesh from_pydata in blender 2.6.3
-# fixed/optimized bin2 method
-# (TJS - 2012-04-30)
-
-
if "bpy" in locals():
- import importlib
- importlib.reload(import_img)
-else:
- from . import import_img
-
-
-import bpy
-from bpy.props import *
-from bpy_extras.io_utils import ImportHelper
-
-
-class ImportHiRISEIMGDTM(bpy.types.Operator, ImportHelper):
- """Import a HiRISE DTM formatted as a PDS IMG file"""
- bl_idname = "import_shape.img"
- bl_label = "Import HiRISE DTM from PDS IMG"
- bl_options = {'UNDO'}
+ import imp
+ imp.reload(importer)
+ imp.reload(terrainpanel)
- filename_ext = ".IMG"
- filter_glob = StringProperty(default="*.IMG", options={'HIDDEN'})
-
- scale = FloatProperty(name="Scale",
- description="Scale the IMG by this value",
- min=0.0001,
- max=10.0,
- soft_min=0.001,
- soft_max=100.0,
- default=0.01)
-
- bin_mode = EnumProperty(items=(
- ('NONE', "None", "Don't bin the image"),
- ('BIN2', "2x2", "use 2x2 binning to import the mesh"),
- ('BIN6', "6x6", "use 6x6 binning to import the mesh"),
- ('BIN6-FAST', "6x6 Fast", "use one sample per 6x6 region"),
- ('BIN12', "12x12", "use 12x12 binning to import the mesh"),
- ('BIN12-FAST', "12x12 Fast", "use one sample per 12x12 region"),
- ),
- name="Binning",
- description="Import Binning",
- default='BIN12-FAST'
- )
-
- ## TODO: add support for cropping on import when the checkbox is checked
- # do_crop = BoolProperty(name="Crop Image", description="Crop the image during import", ... )
- ## we only want these visible when the above is "true"
- # crop_x = IntProperty(name="X", description="Offset from left side of image")
- # crop_y = IntProperty(name="Y", description="Offset from top of image")
- # crop_w = IntProperty(name="Width", description="width of cropped operation")
- # crop_h = IntProperty(name="Height", description="height of cropped region")
- ## This is also a bit ugly and maybe an anti-pattern. The problem is that
- ## importing a HiRISE DTM at full resolution will likely kill any mortal user with
- ## less than 16 GB RAM and getting at specific features in a DTM at full res
- ## may prove beneficial. Someday most mortals will have 16GB RAM.
- ## -TJS 2010-11-23
-
- def execute(self, context):
- filepath = self.filepath
- filepath = bpy.path.ensure_ext(filepath, self.filename_ext)
-
- return import_img.load(self, context,
- filepath=self.filepath,
- scale=self.scale,
- bin_mode=self.bin_mode,
- cropVars=False,
- )
-
-## How to register the script inside of Blender
def menu_import(self, context):
- self.layout.operator(ImportHiRISEIMGDTM.bl_idname, text="HiRISE DTM from PDS IMG (*.IMG)")
+ i = importer.ImportHiRISETerrain
+ self.layout.operator(i.bl_idname, text=i.bl_label)
+
def register():
bpy.utils.register_module(__name__)
-
bpy.types.INFO_MT_file_import.append(menu_import)
+
def unregister():
bpy.utils.unregister_module(__name__)
-
bpy.types.INFO_MT_file_import.remove(menu_import)
-if __name__ == "__main__":
+
+if __name__ == '__main__':
register()
diff --git a/io_convert_image_to_mesh_img/import_img.py b/io_convert_image_to_mesh_img/import_img.py
deleted file mode 100644
index 7c9e76d1..00000000
--- a/io_convert_image_to_mesh_img/import_img.py
+++ /dev/null
@@ -1,713 +0,0 @@
-# ##### BEGIN GPL LICENSE BLOCK #####
-#
-# This program is free software; you can redistribute it and/or
-# modify it under the terms of the GNU General Public License
-# as published by the Free Software Foundation; either version 2
-# of the License, or (at your option) any later version.
-#
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-# GNU General Public License for more details.
-#
-# You should have received a copy of the GNU General Public License
-# along with this program; if not, write to the Free Software Foundation,
-# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
-#
-# ##### END GPL LICENSE BLOCK #####
-
-"""
-This script can import a HiRISE DTM .IMG file.
-"""
-
-import bpy
-from bpy.props import *
-
-from struct import pack, unpack
-import os
-import queue, threading
-
-class image_properties:
- """ keeps track of image attributes throughout the hirise_dtm_importer class """
- def __init__(self, name, dimensions, pixel_scale):
- self.name( name )
- self.dims( dimensions )
- self.processed_dims( dimensions )
- self.pixel_scale( pixel_scale )
-
- def dims(self, dims=None):
- if dims is not None:
- self.__dims = dims
- return self.__dims
-
- def processed_dims(self, processed_dims=None):
- if processed_dims is not None:
- self.__processed_dims = processed_dims
- return self.__processed_dims
-
- def name(self, name=None):
- if name is not None:
- self.__name = name
- return self.__name
-
- def pixel_scale(self, pixel_scale=None):
- if pixel_scale is not None:
- self.__pixel_scale = pixel_scale
- return self.__pixel_scale
-
-class hirise_dtm_importer(object):
- """ methods to understand/import a HiRISE DTM formatted as a PDS .IMG """
-
- def __init__(self, context, filepath):
- self.__context = context
- self.__filepath = filepath
- self.__ignore_value = 0x00000000
- self.__bin_mode = 'BIN6'
- self.scale( 1.0 )
- self.__cropXY = False
-
- def bin_mode(self, bin_mode=None):
- if bin_mode is not None:
- self.__bin_mode = bin_mode
- return self.__bin_mode
-
- def scale(self, scale=None):
- if scale is not None:
- self.__scale = scale
- return self.__scale
-
- def crop(self, widthX, widthY, offX, offY):
- self.__cropXY = [ widthX, widthY, offX, offY ]
- return self.__cropXY
-
- ############################################################################
- ## PDS Label Operations
- ############################################################################
-
- def parsePDSLabel(self, labelIter, currentObjectName=None, level = ""):
- # Let's parse this thing... semi-recursively
- ## I started writing this caring about everything in the PDS standard but ...
- ## it's a mess and I only need a few things -- thar be hacks below
- ## Mostly I just don't care about continued data from previous lines
- label_structure = []
-
- # When are we done with this level?
- endStr = "END"
- if not currentObjectName is None:
- endStr = "END_OBJECT = %s" % currentObjectName
- line = ""
-
- while not line.rstrip() == endStr:
- line = next(labelIter)
-
- # Get rid of comments
- comment = line.find("/*")
- if comment > -1:
- line = line[:comment]
-
- # Take notice of objects
- if line[:8] == "OBJECT =":
- objName = line[8:].rstrip()
- label_structure.append(
- (
- objName.lstrip().rstrip(),
- self.parsePDSLabel(labelIter, objName.lstrip().rstrip(), level + " ")
- )
- )
- elif line.find("END_OBJECT =") > -1:
- pass
- elif len(line.rstrip().lstrip()) > 0:
- key_val = line.split(" = ", 2)
- if len(key_val) == 2:
- label_structure.append( (key_val[0].rstrip().lstrip(), key_val[1].rstrip().lstrip()) )
-
- return label_structure
-
- # There has got to be a better way in python?
- def iterArr(self, label):
- for line in label:
- yield line
-
- def getPDSLabel(self, img):
- # Just takes file and stores it into an array for later use
- label = []
- done = False;
- # Grab label into array of lines
- while not done:
- line = str(img.readline(), 'utf-8')
- if line.rstrip() == "END":
- done = True
- label.append(line)
- return (label, self.parsePDSLabel(self.iterArr(label)))
-
- def getLinesAndSamples(self, label):
- """ uses the parsed PDS Label to get the LINES and LINE_SAMPLES parameters
- from the first object named "IMAGE" -- is hackish
- """
- for obj in label:
- if obj[0] == "IMAGE":
- return self.getLinesAndSamples(obj[1])
- if obj[0] == "LINES":
- lines = int(obj[1])
- if obj[0] == "LINE_SAMPLES":
- line_samples = int(obj[1])
-
- return ( line_samples, lines )
-
- def getValidMinMax(self, label):
- """ uses the parsed PDS Label to get the VALID_MINIMUM and VALID_MAXIMUM parameters
- from the first object named "IMAGE" -- is hackish
- """
- for obj in label:
- if obj[0] == "IMAGE":
- return self.getValidMinMax(obj[1])
- if obj[0] == "VALID_MINIMUM":
- vmin = float(obj[1])
- if obj[0] == "VALID_MAXIMUM":
- vmax = float(obj[1])
-
- return vmin, vmax
-
- def getMissingConstant(self, label):
- """ uses the parsed PDS Label to get the MISSING_CONSTANT parameter
- from the first object named "IMAGE" -- is hackish
- """
- for obj in label:
- if obj[0] == "IMAGE":
- return self.getMissingConstant(obj[1])
- if obj[0] == "MISSING_CONSTANT":
- bit_string_repr = obj[1]
-
- # This is always the same for a HiRISE image, so we are just checking it
- # to be a little less insane here. If someone wants to support another
- # constant then go for it. Just make sure this one continues to work too
- pieces = bit_string_repr.split("#")
- if pieces[0] == "16" and pieces[1] == "FF7FFFFB":
- ignore_value = unpack("f", pack("I", 0xFF7FFFFB))[0]
-
- return ( ignore_value )
-
- ############################################################################
- ## Image operations
- ############################################################################
-
- def bin2(self, image_iter, bin2_method_type="SLOW"):
- """ this is an iterator that: Given an image iterator will yield binned lines """
-
- ignore_value = self.__ignore_value
-
- img_props = next(image_iter)
- # dimensions shrink as we remove pixels
- processed_dims = img_props.processed_dims()
- processed_dims = ( processed_dims[0]//2, processed_dims[1]//2 )
- img_props.processed_dims( processed_dims )
- # each pixel is larger as binning gets larger
- pixel_scale = img_props.pixel_scale()
- pixel_scale = ( pixel_scale[0]*2, pixel_scale[1]*2 )
- img_props.pixel_scale( pixel_scale )
- yield img_props
-
- # Take two lists [a1, a2, a3], [b1, b2, b3] and combine them into one
- # list of [a1 + b1, a2+b2, ... ] as long as both values are not ignorable
- combine_fun = lambda a, b: a != ignore_value and b != ignore_value and (a + b)/2 or ignore_value
-
- line_count = 0
- ret_list = []
- for line in image_iter:
- if line_count == 1:
- line_count = 0
- tmp_list = list(map(combine_fun, line, last_line))
- while len(tmp_list) > 1:
- ret_list.append( combine_fun( tmp_list[0], tmp_list[1] ) )
- del tmp_list[0:2]
- yield ret_list
- ret_list = []
- else:
- last_line = line
- line_count += 1
-
- def bin6(self, image_iter, bin6_method_type="SLOW"):
- """ this is an iterator that: Given an image iterator will yield binned lines """
-
- img_props = next(image_iter)
- # dimensions shrink as we remove pixels
- processed_dims = img_props.processed_dims()
- processed_dims = ( processed_dims[0]//6, processed_dims[1]//6 )
- img_props.processed_dims( processed_dims )
- # each pixel is larger as binning gets larger
- pixel_scale = img_props.pixel_scale()
- pixel_scale = ( pixel_scale[0]*6, pixel_scale[1]*6 )
- img_props.pixel_scale( pixel_scale )
- yield img_props
-
- if bin6_method_type == "FAST":
- bin6_method = self.bin6_real_fast
- else:
- bin6_method = self.bin6_real
-
- raw_data = []
- line_count = 0
- for line in image_iter:
- raw_data.append( line )
- line_count += 1
- if line_count == 6:
- yield bin6_method( raw_data )
- line_count = 0
- raw_data = []
-
- def bin6_real(self, raw_data):
- """ does a 6x6 sample of raw_data and returns a single line of data """
- # TODO: make this more efficient
-
- binned_data = []
-
- # Filter out those unwanted hugely negative values...
- IGNORE_VALUE = self.__ignore_value
-
- base = 0
- for i in range(0, len(raw_data[0])//6):
-
- ints = (raw_data[0][base:base+6] +
- raw_data[1][base:base+6] +
- raw_data[2][base:base+6] +
- raw_data[3][base:base+6] +
- raw_data[4][base:base+6] +
- raw_data[5][base:base+6] )
-
- ints = [num for num in ints if num != IGNORE_VALUE]
-
- # If we have all pesky values, return a pesky value
- if not ints:
- binned_data.append( IGNORE_VALUE )
- else:
- binned_data.append( sum(ints, 0.0) / len(ints) )
-
- base += 6
-
- return binned_data
-
- def bin6_real_fast(self, raw_data):
- """ takes a single value from each 6x6 sample of raw_data and returns a single line of data """
- # TODO: make this more efficient
-
- binned_data = []
-
- base = 0
- for i in range(0, len(raw_data[0])//6):
- binned_data.append( raw_data[0][base] )
- base += 6
-
- return binned_data
-
- def bin12(self, image_iter, bin12_method_type="SLOW"):
- """ this is an iterator that: Given an image iterator will yield binned lines """
-
- img_props = next(image_iter)
- # dimensions shrink as we remove pixels
- processed_dims = img_props.processed_dims()
- processed_dims = ( processed_dims[0]//12, processed_dims[1]//12 )
- img_props.processed_dims( processed_dims )
- # each pixel is larger as binning gets larger
- pixel_scale = img_props.pixel_scale()
- pixel_scale = ( pixel_scale[0]*12, pixel_scale[1]*12 )
- img_props.pixel_scale( pixel_scale )
- yield img_props
-
- if bin12_method_type == "FAST":
- bin12_method = self.bin12_real_fast
- else:
- bin12_method = self.bin12_real
-
- raw_data = []
- line_count = 0
- for line in image_iter:
- raw_data.append( line )
- line_count += 1
- if line_count == 12:
- yield bin12_method( raw_data )
- line_count = 0
- raw_data = []
-
- def bin12_real(self, raw_data):
- """ does a 12x12 sample of raw_data and returns a single line of data """
-
- binned_data = []
-
- # Filter out those unwanted hugely negative values...
- filter_fun = lambda a: self.__ignore_value.__ne__(a)
-
- base = 0
- for i in range(0, len(raw_data[0])//12):
-
- ints = list(filter( filter_fun, raw_data[0][base:base+12] +
- raw_data[1][base:base+12] +
- raw_data[2][base:base+12] +
- raw_data[3][base:base+12] +
- raw_data[4][base:base+12] +
- raw_data[5][base:base+12] +
- raw_data[6][base:base+12] +
- raw_data[7][base:base+12] +
- raw_data[8][base:base+12] +
- raw_data[9][base:base+12] +
- raw_data[10][base:base+12] +
- raw_data[11][base:base+12] ))
- len_ints = len( ints )
-
- # If we have all pesky values, return a pesky value
- if len_ints == 0:
- binned_data.append( self.__ignore_value )
- else:
- binned_data.append( sum(ints) / len(ints) )
-
- base += 12
- return binned_data
-
- def bin12_real_fast(self, raw_data):
- """ takes a single value from each 12x12 sample of raw_data and returns a single line of data """
- return raw_data[0][11::12]
-
- def cropXY(self, image_iter, XSize=None, YSize=None, XOffset=0, YOffset=0):
- """ return a cropped portion of the image """
-
- img_props = next(image_iter)
- # dimensions shrink as we remove pixels
- processed_dims = img_props.processed_dims()
-
- if XSize is None:
- XSize = processed_dims[0]
- if YSize is None:
- YSize = processed_dims[1]
-
- if XSize + XOffset > processed_dims[0]:
- XSize = processed_dims[0]
- XOffset = 0
- if YSize + YOffset > processed_dims[1]:
- YSize = processed_dims[1]
- YOffset = 0
-
- img_props.processed_dims( (XSize, YSize) )
- yield img_props
-
- currentY = 0
- for line in image_iter:
- if currentY >= YOffset and currentY <= YOffset + YSize:
- yield line[XOffset:XOffset+XSize]
- # Not much point in reading the rest of the data...
- if currentY == YOffset + YSize:
- return
- currentY += 1
-
- def getImage(self, img, img_props):
- """ Assumes 32-bit pixels -- bins image """
- dims = img_props.dims()
-
- # setup to unpack more efficiently.
- x_len = dims[0]
- # little endian (PC_REAL)
- unpack_str = "<"
- # unpack_str = ">"
- unpack_bytes_str = "<"
- pack_bytes_str = "="
- # 32 bits/sample * samples/line = y_bytes (per line)
- x_bytes = 4*x_len
- for x in range(0, x_len):
- # 32-bit float is "d"
- unpack_str += "f"
- unpack_bytes_str += "I"
- pack_bytes_str += "I"
-
- # Each iterator yields this first ... it is for reference of the next iterator:
- yield img_props
-
- for y in range(0, dims[1]):
- # pixels is a byte array
- pixels = b''
- while len(pixels) < x_bytes:
- new_pixels = img.read( x_bytes - len(pixels) )
- pixels += new_pixels
- if len(new_pixels) == 0:
- x_bytes = -1
- pixels = []
- if len(pixels) == x_bytes:
- if 0 == 1:
- repacked_pixels = b''
- for integer in unpack(unpack_bytes_str, pixels):
- repacked_pixels += pack("=I", integer)
- yield unpack( unpack_str, repacked_pixels )
- else:
- yield unpack( unpack_str, pixels )
-
- def shiftToOrigin(self, image_iter, image_min_max):
- """ takes a generator and shifts the points by the valid minimum
- also removes points with value self.__ignore_value and replaces them with None
- """
-
- # use the passed in values ...
- valid_min = image_min_max[0]
-
- # pass on dimensions/pixel_scale since we don't modify them here
- yield next(image_iter)
-
-
- # closures rock!
- def normalize_fun(point):
- if point == self.__ignore_value:
- return None
- return point - valid_min
-
- for line in image_iter:
- yield list(map(normalize_fun, line))
-
- def scaleZ(self, image_iter, scale_factor):
- """ scales the mesh values by a factor """
- # pass on dimensions since we don't modify them here
- yield next(image_iter)
-
- scale_factor = self.scale()
-
- def scale_fun(point):
- try:
- return point * scale_factor
- except:
- return None
-
- for line in image_iter:
- yield list(map(scale_fun, line))
-
- def genMesh(self, image_iter):
- """Returns a mesh object from an image iterator this has the
- value-added feature that a value of "None" is ignored
- """
-
- # Get the output image size given the above transforms
- img_props = next(image_iter)
-
- # Let's interpolate the binned DTM with blender -- yay meshes!
- coords = []
- faces = []
- face_count = 0
- coord = -1
- max_x = img_props.processed_dims()[0]
- max_y = img_props.processed_dims()[1]
-
- scale_x = self.scale() * img_props.pixel_scale()[0]
- scale_y = self.scale() * img_props.pixel_scale()[1]
-
- line_count = 0
- # seed the last line (or previous line) with a line
- last_line = next(image_iter)
- point_offset = 0
- previous_point_offset = 0
-
- # Let's add any initial points that are appropriate
- x = 0
- point_offset += len( last_line ) - last_line.count(None)
- for z in last_line:
- if z is not None:
- coords.append( (x*scale_x, 0.0, z) )
- coord += 1
- x += 1
-
- # We want to ignore points with a value of "None" but we also need to create vertices
- # with an index that we can re-create on the next line. The solution is to remember
- # two offsets: the point offset and the previous point offset.
- # these offsets represent the point index that blender gets -- not the number of
- # points we have read from the image
-
- # if "x" represents points that are "None" valued then conceptually this is how we
- # think of point indices:
- #
- # previous line: offset0 x x +1 +2 +3
- # current line: offset1 x +1 +2 +3 x
-
- # once we can map points we can worry about making triangular or square faces to fill
- # the space between vertices so that blender is more efficient at managing the final
- # structure.
-
- # read each new line and generate coordinates+faces
- for dtm_line in image_iter:
-
- # Keep track of where we are in the image
- line_count += 1
- y_val = line_count*-scale_y
-
- # Just add all points blindly
- # TODO: turn this into a map
- x = 0
- for z in dtm_line:
- if z is not None:
- coords.append( (x*scale_x, y_val, z) )
- coord += 1
- x += 1
-
- # Calculate faces
- for x in range(0, max_x - 1):
- vals = [
- last_line[ x + 1 ],
- last_line[ x ],
- dtm_line[ x ],
- dtm_line[ x + 1 ],
- ]
-
- # Two or more values of "None" means we can ignore this block
- none_val = vals.count(None)
-
- # Common case: we can create a square face
- if none_val == 0:
- faces.append( (
- previous_point_offset,
- previous_point_offset+1,
- point_offset+1,
- point_offset,
- ) )
- face_count += 1
- elif none_val == 1:
- # special case: we can implement a triangular face
- ## NB: blender 2.5 makes a triangular face when the last coord is 0
- # TODO: implement a triangular face
- pass
-
- if vals[1] is not None:
- previous_point_offset += 1
- if vals[2] is not None:
- point_offset += 1
-
- # Squeeze the last point offset increment out of the previous line
- if last_line[-1] is not None:
- previous_point_offset += 1
-
- # Squeeze the last point out of the current line
- if dtm_line[-1] is not None:
- point_offset += 1
-
- # remember what we just saw (and forget anything before that)
- last_line = dtm_line
-
- me = bpy.data.meshes.new(img_props.name()) # create a new mesh
- #from_pydata(self, vertices, edges, faces)
- #Make a mesh from a list of vertices/edges/faces
- #Until we have a nicer way to make geometry, use this.
- #:arg vertices:
- # float triplets each representing (X, Y, Z)
- # eg: [(0.0, 1.0, 0.5), ...].
- #:type vertices: iterable object
- #:arg edges:
- # int pairs, each pair contains two indices to the
- # *vertices* argument. eg: [(1, 2), ...]
- #:type edges: iterable object
- #:arg faces:
- # iterator of faces, each faces contains three or more indices to
- # the *vertices* argument. eg: [(5, 6, 8, 9), (1, 2, 3), ...]
- #:type faces: iterable object
- me.from_pydata(coords, [], faces)
-
- # me.vertices.add(len(coords)/3)
- # me.vertices.foreach_set("co", coords)
- # me.faces.add(len(faces)/4)
- # me.faces.foreach_set("vertices_raw", faces)
-
- me.update()
-
- bin_desc = self.bin_mode()
- if bin_desc == 'NONE':
- bin_desc = 'No Bin'
-
- ob=bpy.data.objects.new("DTM - %s" % bin_desc, me)
-
- return ob
-
- ################################################################################
- # Yay, done with importer functions ... let's see the abstraction in action! #
- ################################################################################
- def execute(self):
-
- img = open(self.__filepath, 'rb')
-
- (label, parsedLabel) = self.getPDSLabel(img)
-
- image_dims = self.getLinesAndSamples(parsedLabel)
- img_min_max_vals = self.getValidMinMax(parsedLabel)
- self.__ignore_value = self.getMissingConstant(parsedLabel)
-
-
- # MAGIC VALUE? -- need to formalize this to rid ourselves of bad points
- img.seek(28)
- # Crop off 4 lines
- img.seek(4*image_dims[0])
-
- # HiRISE images (and most others?) have 1m x 1m pixels
- pixel_scale=(1, 1)
-
- # The image we are importing
- image_name = os.path.basename( self.__filepath )
-
- # Set the properties of the image in a manageable object
- img_props = image_properties( image_name, image_dims, pixel_scale )
-
- # Get an iterator to iterate over lines
- image_iter = self.getImage(img, img_props)
-
- ## Wrap the image_iter generator with other generators to modify the dtm on a
- ## line-by-line basis. This creates a stream of modifications instead of reading
- ## all of the data at once, processing all of the data (potentially several times)
- ## and then handing it off to blender
- ## TODO: find a way to alter projection based on transformations below
-
- if self.__cropXY:
- image_iter = self.cropXY(image_iter,
- XSize=self.__cropXY[0],
- YSize=self.__cropXY[1],
- XOffset=self.__cropXY[2],
- YOffset=self.__cropXY[3]
- )
-
- # Select an appropriate binning mode
- ## TODO: generalize the binning fn's
- bin_mode = self.bin_mode()
- bin_mode_funcs = {
- 'BIN2': self.bin2(image_iter),
- 'BIN6': self.bin6(image_iter),
- 'BIN6-FAST': self.bin6(image_iter, 'FAST'),
- 'BIN12': self.bin12(image_iter),
- 'BIN12-FAST': self.bin12(image_iter, 'FAST')
- }
- if bin_mode in bin_mode_funcs.keys():
- image_iter = bin_mode_funcs[ bin_mode ]
-
- image_iter = self.shiftToOrigin(image_iter, img_min_max_vals)
-
- if self.scale != 1.0:
- image_iter = self.scaleZ(image_iter, img_min_max_vals)
-
- # Create a new mesh object and set data from the image iterator
- ob_new = self.genMesh(image_iter)
-
- if img:
- img.close()
-
- # Add mesh object to the current scene
- scene = self.__context.scene
- scene.objects.link(ob_new)
- scene.update()
-
- # deselect other objects
- bpy.ops.object.select_all(action='DESELECT')
-
- # scene.objects.active = ob_new
- # Select the new mesh
- ob_new.select = True
-
- return ('FINISHED',)
-
-def load(operator, context, filepath, scale, bin_mode, cropVars):
- print("Bin Mode: %s" % bin_mode)
- print("Scale: %f" % scale)
- importer = hirise_dtm_importer(context,filepath)
- importer.bin_mode( bin_mode )
- importer.scale( scale )
- if cropVars:
- importer.crop( cropVars[0], cropVars[1], cropVars[2], cropVars[3] )
- importer.execute()
-
- print("Loading %s" % filepath)
- return {'FINISHED'}
diff --git a/io_convert_image_to_mesh_img/mesh/__init__.py b/io_convert_image_to_mesh_img/mesh/__init__.py
new file mode 100644
index 00000000..edbc8c88
--- /dev/null
+++ b/io_convert_image_to_mesh_img/mesh/__init__.py
@@ -0,0 +1,25 @@
+# This file is a part of the HiRISE DTM Importer for Blender
+#
+# Copyright (C) 2017 Arizona Board of Regents on behalf of the Planetary Image
+# Research Laboratory, Lunar and Planetary Laboratory at the University of
+# Arizona.
+#
+# This program is free software: you can redistribute it and/or modify it
+# under the terms of the GNU General Public License as published by the Free
+# Software Foundation, either version 3 of the License, or (at your option)
+# any later version.
+#
+# This program is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
+# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+# for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program. If not, see <http://www.gnu.org/licenses/>.
+
+"""A sub-package for loading DTMs as 3D models"""
+
+from . import dtm
+from . import terrain
+
+__all__ = ['dtm', 'terrain', ]
diff --git a/io_convert_image_to_mesh_img/mesh/dtm.py b/io_convert_image_to_mesh_img/mesh/dtm.py
new file mode 100644
index 00000000..a6ab6e30
--- /dev/null
+++ b/io_convert_image_to_mesh_img/mesh/dtm.py
@@ -0,0 +1,219 @@
+# This file is a part of the HiRISE DTM Importer for Blender
+#
+# Copyright (C) 2017 Arizona Board of Regents on behalf of the Planetary Image
+# Research Laboratory, Lunar and Planetary Laboratory at the University of
+# Arizona.
+#
+# This program is free software: you can redistribute it and/or modify it
+# under the terms of the GNU General Public License as published by the Free
+# Software Foundation, either version 3 of the License, or (at your option)
+# any later version.
+#
+# This program is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
+# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+# for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program. If not, see <http://www.gnu.org/licenses/>.
+
+"""Objects for importing HiRISE DTMs."""
+
+import numpy as np
+
+from .. import pvl
+
+
+class DTM:
+ """
+ HiRISE Digital Terrain Model
+
+ This class imports a HiRISE DTM from a Planetary Data Systems (PDS)
+ compliant .IMG file.
+
+ Parameters
+ ----------
+ path : str
+ terrain_resolution : float, optional
+ Controls the resolution the DTM is read at. This should be a float
+ in the range [0.01, 1.0] (and will be constrained to this range). A
+ value of 1.0 will result in the DTM being read at full resolution. A
+ value of 0.01 will result in the DTM being read at 1/100th resolution.
+ Default is 1.0 (no downsampling).
+
+ Todo
+ ----
+ * Use GDAL for importing the DTM if it is installed for this Python
+ environment. If/when I have the time to do this, it probably
+ warrants breaking out separate importer classes. The benefits of
+ doing this are pretty substantial, though:
+
+ + More reliable (doesn't rely on my PVL parser for finding the
+ valid values in the DTM, for locating the starting position of
+ the elevation data in the .IMG file)
+
+ + Other, better, downsampling algorithms are already built in.
+
+ + Would make this much better at general PDS DTM importing,
+ currently some of the import code is specific to HiRISE DTMs.
+
+ """
+
+ # Special constants in our data:
+ # NULL : No data at this point.
+ # LRS : Low Representation Saturation
+ # LIS : Low Instrument Saturation
+ # HRS : High Representation Saturation
+ # HIS : High Insturment Saturation
+ SPECIAL_VALUES = {
+ "NULL": np.fromstring(b'\xFF\x7F\xFF\xFB', dtype='>f4')[0],
+ "LRS": np.fromstring(b'\xFF\x7F\xFF\xFC', dtype='>f4')[0],
+ "LIS": np.fromstring(b'\xFF\x7F\xFF\xFD', dtype='>f4')[0],
+ "HRS": np.fromstring(b'\xFF\x7F\xFF\xFE', dtype='>f4')[0],
+ "HIS": np.fromstring(b'\xFF\x7F\xFF\xFF', dtype='>f4')[0]
+ }
+
+ def __init__(self, path, terrain_resolution=1.0):
+ self.path = path
+ self.terrain_resolution = terrain_resolution
+ self.label = self._read_label()
+ self.data = self._read_data()
+
+ def _read_label(self):
+ """Returns a dict-like representation of a PVL label"""
+ return pvl.load(self.path)
+
+ def _read_data(self):
+ """
+ Reads elevation data from a PDS .IMG file.
+
+ Notes
+ -----
+ * Uses nearest-neighbor to downsample data.
+
+ Todo
+ ----
+ * Add other downsampling algorithms.
+
+ """
+ h, w = self.image_resolution
+ max_samples = int(w - w % self.bin_size)
+
+ data = np.zeros(self.shape)
+ with open(self.path, 'rb') as f:
+ # Seek to the first byte of data
+ start_byte = self._get_data_start()
+ f.seek(start_byte)
+ # Iterate over each row of the data
+ for r in range(data.shape[0]):
+ # Each iteration, seek to the right location before
+ # reading a row. We determine this location as the
+ # first byte of data PLUS a offset which we calculate as the
+ # product of:
+ #
+ # 4, the number of bytes in a single record
+ # r, the current row index
+ # w, the number of records in a row of the DTM
+ # bin_size, the number of records in a bin
+ #
+ # This is where we account for skipping over rows.
+ offset = int(4 * r * w * self.bin_size)
+ f.seek(start_byte + offset)
+ # Read a row
+ row = np.fromfile(f, dtype=np.float32, count=max_samples)
+ # This is where we account for skipping over columns.
+ data[r] = row[::self.bin_size]
+
+ data = self._process_invalid_data(data)
+ return data
+
+ def _get_data_start(self):
+ """Gets the start position of the DTM data block"""
+ label_length = self.label['RECORD_BYTES']
+ num_labels = self.label.get('LABEL_RECORDS', 1)
+ return int(label_length * num_labels)
+
+ def _process_invalid_data(self, data):
+ """Sets any 'NULL' elevation values to np.NaN"""
+ invalid_data_mask = (data <= self.SPECIAL_VALUES['NULL'])
+ data[invalid_data_mask] = np.NaN
+ return data
+
+ @property
+ def map_size(self):
+ """Geographic size of the bounding box around the DTM"""
+ scale = self.map_scale * self.unit_scale
+ w = self.image_resolution[0] * scale
+ h = self.image_resolution[1] * scale
+ return (w, h)
+
+ @property
+ def mesh_scale(self):
+ """Geographic spacing between mesh vertices"""
+ return self.bin_size * self.map_scale * self.unit_scale
+
+ @property
+ def map_info(self):
+ """Map Projection metadata"""
+ return self.label['IMAGE_MAP_PROJECTION']
+
+ @property
+ def map_scale(self):
+ """Geographic spacing between DTM posts"""
+ map_scale = self.map_info.get('MAP_SCALE', None)
+ return getattr(map_scale, 'value', 1.0)
+
+ @property
+ def map_units(self):
+ """Geographic unit for spacing between DTM posts"""
+ map_scale = self.map_info.get('MAP_SCALE', None)
+ return getattr(map_scale, 'units', None)
+
+ @property
+ def unit_scale(self):
+ """
+ The function that creates a Blender mesh from this object will assume
+ that the height values passed into it are in meters --- this
+ property is a multiplier to convert DTM-units to meters.
+ """
+ scaling_factors = {
+ 'KM/PIXEL': 1000,
+ 'METERS/PIXEL': 1
+ }
+ return scaling_factors.get(self.map_units, 1.0)
+
+ @property
+ def terrain_resolution(self):
+ """Vertex spacing, meters"""
+ return self._terrain_resolution
+
+ @terrain_resolution.setter
+ def terrain_resolution(self, t):
+ self._terrain_resolution = np.clip(t, 0.01, 1.0)
+
+ @property
+ def bin_size(self):
+ """The width of the (square) downsampling bin"""
+ return int(np.ceil(1 / self.terrain_resolution))
+
+ @property
+ def image_stats(self):
+ """Image statistics from the original DTM label"""
+ return self.label['IMAGE']
+
+ @property
+ def image_resolution(self):
+ """(Line, Sample) resolution of the original DTM"""
+ return (self.image_stats['LINES'], self.image_stats['LINE_SAMPLES'])
+
+ @property
+ def size(self):
+ """Number of posts in our reduced DTM"""
+ return self.shape[0] * self.shape[1]
+
+ @property
+ def shape(self):
+ """Shape of our reduced DTM"""
+ num_rows = self.image_resolution[0] // self.bin_size
+ num_cols = self.image_resolution[1] // self.bin_size
+ return (num_rows, num_cols)
diff --git a/io_convert_image_to_mesh_img/mesh/terrain.py b/io_convert_image_to_mesh_img/mesh/terrain.py
new file mode 100644
index 00000000..db866289
--- /dev/null
+++ b/io_convert_image_to_mesh_img/mesh/terrain.py
@@ -0,0 +1,245 @@
+# This file is a part of the HiRISE DTM Importer for Blender
+#
+# Copyright (C) 2017 Arizona Board of Regents on behalf of the Planetary Image
+# Research Laboratory, Lunar and Planetary Laboratory at the University of
+# Arizona.
+#
+# This program is free software: you can redistribute it and/or modify it
+# under the terms of the GNU General Public License as published by the Free
+# Software Foundation, either version 3 of the License, or (at your option)
+# any later version.
+#
+# This program is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
+# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+# for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program. If not, see <http://www.gnu.org/licenses/>.
+
+"""Objects for creating 3D models in Blender"""
+
+import bpy
+import bmesh
+
+import numpy as np
+
+from .triangulate import Triangulate
+
+
+class BTerrain:
+ """
+ Functions for creating Blender meshes from DTM objects
+
+ This class contains functions that convert DTM objects to Blender meshes.
+ Its main responsiblity is to triangulate a mesh from the elevation data in
+ the DTM. Additionally, it attaches some metadata to the object and creates
+ a UV map for it so that companion ortho-images drape properly.
+
+ This class provides two public methods: `new()` and `reload()`.
+
+ `new()` creates a new object[1] and attaches a new mesh to it.
+
+ `reload()` replaces the mesh that is attached to an already existing
+ object. This allows us to retain the location and orientation of the parent
+ object's coordinate system but to reload the terrain at a different
+ resolution.
+
+ Notes
+ ----------
+ [1] If you're unfamiliar with Blender, one thing that will help you in
+ reading this code is knowing the difference between 'meshes' and
+ 'objects'. A mesh is just a collection of vertices, edges and
+ faces. An object may have a mesh as a child data object and
+ contains additional information, e.g. the location and orientation
+ of the coordinate system its child-meshes are reckoned in terms of.
+
+ """
+
+ @staticmethod
+ def new(dtm, name='Terrain'):
+ """
+ Loads a new terrain
+
+ Parameters
+ ----------
+ dtm : DTM
+ name : str, optional
+ The name that will be assigned to the new object, defaults
+ to 'Terrain' (and, if an object named 'Terrain' already
+ exists, Blender will automatically extend the name of the
+ new object to something like 'Terrain.001')
+
+ Returns
+ ----------
+ obj : bpy_types.Object
+
+ """
+ bpy.ops.object.add(type="MESH")
+ obj = bpy.context.object
+ obj.name = name
+
+ # Fill the object data with a Terrain mesh
+ obj.data = BTerrain._mesh_from_dtm(dtm)
+
+ # Add some meta-information to the object
+ metadata = BTerrain._create_metadata(dtm)
+ BTerrain._setobjattrs(obj, **metadata)
+
+ # Center the mesh to its origin and create a UV map for draping
+ # ortho images.
+ BTerrain._center(obj)
+
+ return obj
+
+ @staticmethod
+ def reload(obj, dtm):
+ """
+ Replaces an exisiting object's terrain mesh
+
+ This replaces an object's mesh with a new mesh, transferring old
+ materials over to the new mesh. This is useful for reloading DTMs
+ at different resolutions but maintaining textures/location/rotation.
+
+ Parameters
+ -----------
+ obj : bpy_types.Object
+ An already existing Blender object
+ dtm : DTM
+
+ Returns
+ ----------
+ obj : bpy_types.Object
+
+ """
+ old_mesh = obj.data
+ new_mesh = BTerrain._mesh_from_dtm(dtm)
+
+ # Copy any old materials to the new mesh
+ for mat in old_mesh.materials:
+ new_mesh.materials.append(mat.copy())
+
+ # Swap out the old mesh for the new one
+ obj.data = new_mesh
+
+ # Update out-dated meta-information
+ metadata = BTerrain._create_metadata(dtm)
+ BTerrain._setobjattrs(obj, **metadata)
+
+ # Center the mesh to its origin and create a UV map for draping
+ # ortho images.
+ BTerrain._center(obj)
+
+ return obj
+
+ @staticmethod
+ def _mesh_from_dtm(dtm, name='Terrain'):
+ """
+ Creates a Blender *mesh* from a DTM
+
+ Parameters
+ ----------
+ dtm : DTM
+ name : str, optional
+ The name that will be assigned to the new mesh, defaults
+ to 'Terrain' (and, if an object named 'Terrain' already
+ exists, Blender will automatically extend the name of the
+ new object to something like 'Terrain.001')
+
+ Returns
+ ----------
+ mesh : bpy_types.Mesh
+
+ Notes
+ ----------
+ * We are switching coordinate systems from the NumPy to Blender.
+
+ Numpy: Blender:
+ + ----> (0, j) ^ (0, y)
+ | |
+ | |
+ v (i, 0) + ----> (x, 0)
+
+ """
+ # Create an empty mesh
+ mesh = bpy.data.meshes.new(name)
+
+ # Get the xy-coordinates from the DTM, see docstring notes
+ y, x = np.indices(dtm.data.shape).astype('float64')
+ x *= dtm.mesh_scale
+ y *= -1 * dtm.mesh_scale
+
+ # Create an array of 3D vertices
+ vertices = np.dstack([x, y, dtm.data]).reshape((-1, 3))
+
+ # Drop vertices with NaN values (used in the DTM to represent
+ # areas with no data)
+ vertices = vertices[~np.isnan(vertices).any(axis=1)]
+
+ # Calculate the faces of the mesh
+ triangulation = Triangulate(dtm.data)
+ faces = triangulation.face_list()
+
+ # Fill the mesh
+ mesh.from_pydata(vertices, [], faces)
+ mesh.update()
+
+ # Create a new UV layer
+ mesh.uv_textures.new("HiRISE Generated UV Map")
+ # We'll use a bmesh to populate the UV map with values
+ bm = bmesh.new()
+ bm.from_mesh(mesh)
+ bm.faces.ensure_lookup_table()
+ uv_layer = bm.loops.layers.uv[0]
+
+ # Iterate over each face in the bmesh
+ num_faces = len(bm.faces)
+ w = dtm.data.shape[1]
+ h = dtm.data.shape[0]
+ for face_index in range(num_faces):
+ # Iterate over each loop in the face
+ for loop in bm.faces[face_index].loops:
+ # Get this loop's vertex coordinates
+ vert_coords = loop.vert.co.xy
+ # And calculate it's uv coordinate. We do this by dividing the
+ # vertice's x and y coordinates by:
+ #
+ # d + 1, dimensions of DTM (in "posts")
+ # mesh_scale, meters/DTM "post"
+ #
+ # This has the effect of mapping the vertex to its
+ # corresponding "post" index in the DTM, and then mapping
+ # that value to the range [0, 1).
+ u = vert_coords.x / ((w + 1) * dtm.mesh_scale)
+ v = 1 + vert_coords.y / ((h + 1) * dtm.mesh_scale)
+ loop[uv_layer].uv = (u, v)
+
+ bm.to_mesh(mesh)
+
+ return mesh
+
+ @staticmethod
+ def _center(obj):
+ """Move object geometry to object origin"""
+ bpy.context.scene.objects.active = obj
+ bpy.ops.object.origin_set(center='BOUNDS')
+
+ @staticmethod
+ def _setobjattrs(obj, **attrs):
+ for key, value in attrs.items():
+ obj[key] = value
+
+ @staticmethod
+ def _create_metadata(dtm):
+ """Returns a dict containing meta-information about a DTM"""
+ return {
+ 'PATH': dtm.path,
+ 'MESH_SCALE': dtm.mesh_scale,
+ 'DTM_RESOLUTION': dtm.terrain_resolution,
+ 'BIN_SIZE': dtm.bin_size,
+ 'MAP_SIZE': dtm.map_size,
+ 'MAP_SCALE': dtm.map_scale * dtm.unit_scale,
+ 'UNIT_SCALE': dtm.unit_scale,
+ 'IS_TERRAIN': True,
+ 'HAS_UV_MAP': True
+ }
diff --git a/io_convert_image_to_mesh_img/mesh/triangulate.py b/io_convert_image_to_mesh_img/mesh/triangulate.py
new file mode 100644
index 00000000..bb0a50ac
--- /dev/null
+++ b/io_convert_image_to_mesh_img/mesh/triangulate.py
@@ -0,0 +1,186 @@
+# This file is a part of the HiRISE DTM Importer for Blender
+#
+# Copyright (C) 2017 Arizona Board of Regents on behalf of the Planetary Image
+# Research Laboratory, Lunar and Planetary Laboratory at the University of
+# Arizona.
+#
+# This program is free software: you can redistribute it and/or modify it
+# under the terms of the GNU General Public License as published by the Free
+# Software Foundation, either version 3 of the License, or (at your option)
+# any later version.
+#
+# This program is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
+# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+# for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program. If not, see <http://www.gnu.org/licenses/>.
+
+"""Triangulation algorithms"""
+
+import numpy as np
+
+
+class Triangulate:
+ """
+ A triangulation algorithm for creating a mesh from a DTM raster.
+
+ I have been re-writing parts of the Blender HiRISE DTM importer in an
+ effort to cull its dependencies on external packages. Originally, the
+ add-on relied on SciPy's Delaunay triangulation (really a wrapper for
+ Qhull's Delaunay triangulation) to triangulate a mesh from a HiRISE DTM.
+
+ This re-write is much better suited to the problem domain. The SciPy
+ Delaunay triangulation creates a mesh from any arbitrary point cloud and,
+ while robust, doesn't care about the fact that our HiRISE DTMs are
+ regularly gridded rasters. This triangulation algorithm is less robust
+ but much faster. Credit is due to Tim Spriggs for his work on the previous
+ Blender HiRISE DTM importer --- this triangulation algorithm largely
+ models the one in his add-on with a few changes (namely interfacing
+ with NumPy's API).
+
+ Overview
+ ----------
+ Suppose we have a DTM:
+
+ .. code::
+
+ - - - - - - - - X X - - - - -
+ - - - - - - X X X X X - - - -
+ - - - - X X X X X X X X - - -
+ - - X X X X X X X X X X X - -
+ X X X X X X X X X X X X X X -
+ - X X X X X X X X X X X X X X
+ - - X X X X X X X X X X X - -
+ - - - X X X X X X X X - - - -
+ - - - - X X X X X - - - - - -
+ - - - - - X X - - - - - - - -
+
+ where 'X' represents valid values and '-' represents invalid values.
+ Valid values should become vertices in the resulting mesh, invalid
+ values should be ignored.
+
+ Our end goal is to supply Blender with:
+
+ 1. an (n x 3) list of vertices
+
+ 2. an (m x 3) list of faces.
+
+ A vertex is a 3-tuple that we get from the DTM raster array. The
+ z-coordinate is whatever elevation value is in the DTM and the xy-
+ coordinates are the image indices multiplied by the resolution of the
+ DTM (e.g. if the DTM is at 5m/px, the first vertex is at (0m, 0m,
+ z_00) and the vertex to the right of it is at (5m, 0m, z_01)).
+
+ A face is a 3-tuple (because we're using triangles) where each element
+ is an index of a vertex in the vertices list. Computing the faces is
+ tricky because we want to leverage the orthogonal structure of the DTM
+ raster for computational efficiency but we also need to reference
+ vertex indices in our faces, which don't observe any regular
+ structure.
+
+ We take two rows at a time from the DTM raster and track the *raster
+ row* indices as well as well as the *vertex* indices. Raster row
+ indices are the distance of a pixel in the raster from the left-most
+ (valid *or* invalid) pixel of the row. The first vertex is index 0 and
+ corresponds to the upperleft-most valid pixel in the DTM raster.
+ Vertex indices increase to the right and then down.
+
+ For example, the first two rows:
+
+ .. code::
+
+ - - - - - - - - X X - - - - -
+ - - - - - - X X X X X - - - -
+
+ in vertex indices:
+
+ .. code::
+
+ - - - - - - - - 0 1 - - - - -
+ - - - - - - 2 3 4 5 6 - - - -
+
+ and in raster row indices:
+
+ .. code::
+
+ - - - - - - - - 9 10 - - - - -
+ - - - - - - 7 8 9 10 11 - - - -
+
+ To simplify, we will only add valid square regions to our mesh. So,
+ for these first two rows the only region that will be added to our
+ mesh is the quadrilateral formed by vertices 0, 1, 4 and 5. We
+ further divide this area into 2 triangles and add the vertices to the
+ face list in CCW order (i.e. t1: (4, 1, 0), t2: (4, 5, 1)).
+
+ After the triangulation between two rows is completed, the bottom
+ row is cached as the top row and the next row in the DTM raster is
+ read as the new bottom row. This process continues until the entire
+ raster has been triangulated.
+
+ Todo
+ ---------
+ * It should be pretty trivial to add support for triangular
+ regions (i.e. in the example above, also adding the triangles
+ formed by (3, 4, 0) and (5, 6, 1)).
+
+ """
+ def __init__(self, array):
+ self.array = array
+ self.faces = self._triangulate()
+
+ def _triangulate(self):
+ """Triangulate a mesh from a topography array."""
+ # Allocate memory for the triangles array
+ max_tris = (self.array.shape[0] - 1) * (self.array.shape[1] - 1) * 2
+ tris = np.zeros((max_tris, 3), dtype=int)
+ ntri = 0
+
+ # We initialize a vertex counter at 0
+ prev_vtx_start = 0
+ # We don't care about the values in the array, just whether or not
+ # they are valid.
+ prev = ~np.isnan(self.array[0])
+ # We can sum this boolean array to count the number of valid entries
+ prev_num_valid = prev.sum()
+ # TODO: Probably a more clear (and faster) function than argmax for
+ # getting the first Truth-y value in a 1d array.
+ prev_img_start = np.argmax(prev)
+
+ # Start quadrangulation
+ for i in range(1, self.array.shape[0]):
+ # Fetch this row, get our bearings in image *and* vertex space
+ curr = ~np.isnan(self.array[i])
+ curr_vtx_start = prev_vtx_start + prev_num_valid
+ curr_img_start = np.argmax(curr)
+ curr_num_valid = curr.sum()
+ # Find the overlap between this row and the previous one
+ overlap = np.logical_and(prev, curr)
+ num_tris = overlap.sum() - 1
+ overlap_start = np.argmax(overlap)
+ # Store triangles
+ for j in range(num_tris):
+ curr_pad = overlap_start - curr_img_start + j
+ prev_pad = overlap_start - prev_img_start + j
+ tris[ntri + 0] = [
+ curr_vtx_start + curr_pad,
+ prev_vtx_start + prev_pad + 1,
+ prev_vtx_start + prev_pad
+ ]
+ tris[ntri + 1] = [
+ curr_vtx_start + curr_pad,
+ curr_vtx_start + curr_pad + 1,
+ prev_vtx_start + prev_pad + 1
+ ]
+ ntri += 2
+ # Cache current row as previous row
+ prev = curr
+ prev_vtx_start = curr_vtx_start
+ prev_img_start = curr_img_start
+ prev_num_valid = curr_num_valid
+
+ return tris[:ntri]
+
+ def face_list(self):
+ return list(self.faces)
diff --git a/io_convert_image_to_mesh_img/pvl/__init__.py b/io_convert_image_to_mesh_img/pvl/__init__.py
new file mode 100644
index 00000000..4fcf394e
--- /dev/null
+++ b/io_convert_image_to_mesh_img/pvl/__init__.py
@@ -0,0 +1,27 @@
+# This file is a part of the HiRISE DTM Importer for Blender
+#
+# Copyright (C) 2017 Arizona Board of Regents on behalf of the Planetary Image
+# Research Laboratory, Lunar and Planetary Laboratory at the University of
+# Arizona.
+#
+# This program is free software: you can redistribute it and/or modify it
+# under the terms of the GNU General Public License as published by the Free
+# Software Foundation, either version 3 of the License, or (at your option)
+# any later version.
+#
+# This program is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
+# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+# for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program. If not, see <http://www.gnu.org/licenses/>.
+
+"""A sub-package for parsing PVL labels"""
+
+from .parse import LabelParser
+
+
+def load(path):
+ """Returns a dict-like representation of a PVL label"""
+ return LabelParser.load(path)
diff --git a/io_convert_image_to_mesh_img/pvl/label.py b/io_convert_image_to_mesh_img/pvl/label.py
new file mode 100644
index 00000000..68d4f914
--- /dev/null
+++ b/io_convert_image_to_mesh_img/pvl/label.py
@@ -0,0 +1,24 @@
+# This file is a part of the HiRISE DTM Importer for Blender
+#
+# Copyright (C) 2017 Arizona Board of Regents on behalf of the Planetary Image
+# Research Laboratory, Lunar and Planetary Laboratory at the University of
+# Arizona.
+#
+# This program is free software: you can redistribute it and/or modify it
+# under the terms of the GNU General Public License as published by the Free
+# Software Foundation, either version 3 of the License, or (at your option)
+# any later version.
+#
+# This program is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
+# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+# for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program. If not, see <http://www.gnu.org/licenses/>.
+
+
+class Label(dict):
+ """A dict-like representation of a PVL label"""
+ def __init__(self, *args, **kwargs):
+ super(Label, self).__init__(*args, **kwargs)
diff --git a/io_convert_image_to_mesh_img/pvl/parse.py b/io_convert_image_to_mesh_img/pvl/parse.py
new file mode 100644
index 00000000..3a87d2f3
--- /dev/null
+++ b/io_convert_image_to_mesh_img/pvl/parse.py
@@ -0,0 +1,147 @@
+# This file is a part of the HiRISE DTM Importer for Blender
+#
+# Copyright (C) 2017 Arizona Board of Regents on behalf of the Planetary Image
+# Research Laboratory, Lunar and Planetary Laboratory at the University of
+# Arizona.
+#
+# This program is free software: you can redistribute it and/or modify it
+# under the terms of the GNU General Public License as published by the Free
+# Software Foundation, either version 3 of the License, or (at your option)
+# any later version.
+#
+# This program is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
+# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+# for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program. If not, see <http://www.gnu.org/licenses/>.
+
+"""PVL Label Parsing"""
+
+import collections
+import re
+
+from . import patterns
+from .label import Label
+
+Quantity = collections.namedtuple('Quantity', ['value', 'units'])
+
+
+class PVLParseError(Exception):
+ """Error parsing PVL file"""
+ def __init__(self, message):
+ super(PVLParseError, self).__init__(message)
+
+
+class LabelParser:
+ """A PVL Parser"""
+ @staticmethod
+ def load(path):
+ """
+ Load a dict-like representation of a PVL label header
+
+ Parameters
+ ----------
+ path : str
+ Path to a file containing a PVL header
+
+ Returns
+ ----------
+ label : pvl.Label
+
+ """
+ raw = LabelParser._read(path)
+ return Label(**LabelParser._parse(raw))
+
+ @staticmethod
+ def _read(path):
+ """
+ Get the PVL header from a file as a string
+
+ Parameters
+ ----------
+ path : str
+ Path to a file containing a PVL header
+
+ Returns
+ ----------
+ raw : str
+
+ Notes
+ ---------
+ * This function assumes that the file begins with a PVL header
+ and it will read lines from the file until it encounters
+ a PVL end statement.
+
+ To-Do
+ ---------
+ * This could be more robust. What happens if there is no label
+ in the file?
+
+ """
+ with open(path, 'rb') as f:
+ raw = ''
+ while True:
+ try:
+ line = f.readline().decode()
+ raw += line
+ if re.match(patterns.END, line):
+ break
+ except UnicodeDecodeError:
+ raise PVLParseError("Error parsing PVL label from "
+ "file: {}".format(path))
+ return raw
+
+ @staticmethod
+ def _remove_comments(raw):
+ return re.sub(patterns.COMMENT, '', raw)
+
+ @staticmethod
+ def _parse(raw):
+ raw = LabelParser._remove_comments(raw)
+ label_iter = re.finditer(patterns.STATEMENT, raw)
+ return LabelParser._parse_iter(label_iter)
+
+ @staticmethod
+ def _parse_iter(label_iter):
+ """Recursively parse a PVL label iterator"""
+ obj = {}
+ while True:
+ try:
+ # Try to fetch the next match from the iter
+ match = next(label_iter)
+ val = match.group('val')
+ key = match.group('key')
+ # Handle nested object groups
+ if key == 'OBJECT':
+ obj.update({
+ val: LabelParser._parse_iter(label_iter)
+ })
+ elif key == 'END_OBJECT':
+ return obj
+ # Add key/value pair to dict
+ else:
+ # Should this value be a numeric type?
+ try:
+ val = LabelParser._convert_to_numeric(val)
+ except ValueError:
+ pass
+ # Should this value have units?
+ if match.group('units'):
+ val = Quantity(val, match.group('units'))
+ # Add it to the dict
+ obj.update({key: val})
+ except StopIteration:
+ break
+ return obj
+
+ @staticmethod
+ def _convert_to_numeric(s):
+ """Convert a string to its appropriate numeric type"""
+ if re.match(patterns.INTEGER, s):
+ return int(s)
+ elif re.match(patterns.FLOATING, s):
+ return float(s)
+ else:
+ raise ValueError
diff --git a/io_convert_image_to_mesh_img/pvl/patterns.py b/io_convert_image_to_mesh_img/pvl/patterns.py
new file mode 100644
index 00000000..af921803
--- /dev/null
+++ b/io_convert_image_to_mesh_img/pvl/patterns.py
@@ -0,0 +1,59 @@
+# This file is a part of the HiRISE DTM Importer for Blender
+#
+# Copyright (C) 2017 Arizona Board of Regents on behalf of the Planetary Image
+# Research Laboratory, Lunar and Planetary Laboratory at the University of
+# Arizona.
+#
+# This program is free software: you can redistribute it and/or modify it
+# under the terms of the GNU General Public License as published by the Free
+# Software Foundation, either version 3 of the License, or (at your option)
+# any later version.
+#
+# This program is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
+# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+# for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program. If not, see <http://www.gnu.org/licenses/>.
+
+"""PVL Regular Expression Patterns"""
+
+import re
+
+# End of PVL File
+END = re.compile(
+ r'\s* \bEND\b \s*', (re.VERBOSE + re.IGNORECASE)
+)
+
+# PVL Comment
+COMMENT = re.compile(
+ r'/\* .*? \*/', (re.DOTALL + re.VERBOSE)
+)
+
+# PVL Statement
+STATEMENT = re.compile(
+ r"""
+ \s* (?P<key>\w+) # Match a PVL key
+ \s* = \s* # Who knows how many spaces we encounter
+ (?P<val> # Match a PVL value
+ ([+-]?\d+\.?\d*) # We could match a number
+ | (['"]?((\w+ \s*?)+)['"]?) # Or a string
+ )
+ (\s* <(?P<units>.*?) >)? # The value may have an associated unit
+ """, re.VERBOSE
+)
+
+# Integer Number
+INTEGER = re.compile(
+ r"""
+ [+-]?(?<!\.)\b[0-9]+\b(?!\.[0-9])
+ """, re.VERBOSE
+)
+
+# Floating Point Number
+FLOATING = re.compile(
+ r"""
+ [+-]?\b[0-9]*\.?[0-9]+
+ """, re.VERBOSE
+)
diff --git a/io_convert_image_to_mesh_img/ui/__init__.py b/io_convert_image_to_mesh_img/ui/__init__.py
new file mode 100644
index 00000000..57bd9fde
--- /dev/null
+++ b/io_convert_image_to_mesh_img/ui/__init__.py
@@ -0,0 +1,25 @@
+# This file is a part of the HiRISE DTM Importer for Blender
+#
+# Copyright (C) 2017 Arizona Board of Regents on behalf of the Planetary Image
+# Research Laboratory, Lunar and Planetary Laboratory at the University of
+# Arizona.
+#
+# This program is free software: you can redistribute it and/or modify it
+# under the terms of the GNU General Public License as published by the Free
+# Software Foundation, either version 3 of the License, or (at your option)
+# any later version.
+#
+# This program is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
+# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+# for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program. If not, see <http://www.gnu.org/licenses/>.
+
+"""A sub-package for Blender UI elements"""
+
+from . import importer
+from . import terrainpanel
+
+__all__ = ['importer', 'terrainpanel', ]
diff --git a/io_convert_image_to_mesh_img/ui/importer.py b/io_convert_image_to_mesh_img/ui/importer.py
new file mode 100644
index 00000000..981e49c2
--- /dev/null
+++ b/io_convert_image_to_mesh_img/ui/importer.py
@@ -0,0 +1,177 @@
+# This file is a part of the HiRISE DTM Importer for Blender
+#
+# Copyright (C) 2017 Arizona Board of Regents on behalf of the Planetary Image
+# Research Laboratory, Lunar and Planetary Laboratory at the University of
+# Arizona.
+#
+# This program is free software: you can redistribute it and/or modify it
+# under the terms of the GNU General Public License as published by the Free
+# Software Foundation, either version 3 of the License, or (at your option)
+# any later version.
+#
+# This program is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
+# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+# for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program. If not, see <http://www.gnu.org/licenses/>.
+
+"""Blender menu importer for loading a DTM"""
+
+import bpy
+import bpy.props
+from bpy_extras.io_utils import ImportHelper
+
+from ..mesh.terrain import BTerrain
+from ..mesh.dtm import DTM
+
+
+class ImportHiRISETerrain(bpy.types.Operator, ImportHelper):
+ """DTM Import Helper"""
+ bl_idname = "import.pds_dtm"
+ bl_label = "Import HiRISE Terrain Model"
+ bl_options = {'UNDO'}
+ filename_ext = ".img"
+ filter_glob = bpy.props.StringProperty(
+ options={'HIDDEN'},
+ default="*.img"
+ )
+
+ # Allow the user to specify a resolution factor for loading the
+ # terrain data at. This is useful because it allows the user to stage
+ # a scene with a low resolution terrain map, apply textures, modifiers,
+ # etc. and then increase the resolution to prepare for final rendering.
+ #
+ # Displaying this value as a percentage (0, 100] is an intuitive way
+ # for users to grasp what this value does. The DTM importer, however,
+ # wants to recieve a value between (0, 1]. This is obviously a
+ # straightforward conversion:
+ #
+ # f(x) = x / 100
+ #
+ # But this conversion should happen here, in the terrain panel, rather
+ # than in the DTM importing utility itself. We can't pass get/set
+ # functions to the property itself because they result in a recursion
+ # error. Instead, we use another, hidden, property to store the scaled
+ # resolution.
+ dtm_resolution = bpy.props.FloatProperty(
+ subtype="PERCENTAGE",
+ description=(
+ "Percentage scale for terrain model resolution. 100\% loads the "
+ "model at full resolution (i.e. one vertex for each post in the "
+ "original terrain model) and is *MEMORY INTENSIVE*. Downsampling "
+ "uses Nearest Neighbors. You will be able to increase the "
+ "resolution of your mesh later, and still maintain all textures, "
+ "transformations, modifiers, etc., so best practice is to start "
+ "small. The downsampling algorithm may need to alter the "
+ "resolution you specify here to ensure it results in a whole "
+ "number of vertices. If it needs to alter the value you specify, "
+ "you are guaranteed that it will shrink it (i.e. decrease the "
+ "DTM resolution."
+ ),
+ name="Terrain Model Resolution",
+ min=1.0, max=100.0, default=10.0
+ )
+ scaled_dtm_resolution = bpy.props.FloatProperty(
+ options={'HIDDEN'},
+ name="Scaled Terrain Model Resolution",
+ get=(lambda self: self.dtm_resolution / 100)
+ )
+
+ # HiRISE DTMs are huge, but it can be nice to load them in at scale. Here,
+ # we present the user with the option of setting up the Blender viewport
+ # to avoid a couple of common pitfalls encountered when working with such
+ # a large mesh.
+ #
+ # 1. The Blender viewport has a default clipping distance of 1km. HiRISE
+ # DTMs are often many kilometers in each direction. If this setting is
+ # not changed, an unsuspecting user may only see part (or even nothing
+ # at all) of the terrain. This option (true, by default) instructs
+ # Blender to change the clipping distance to something appropriate for
+ # the DTM, and scales the grid floor to have gridlines 1km apart,
+ # instead of 1m apart.
+ should_setup_viewport = bpy.props.BoolProperty(
+ description=(
+ "Set up the Blender screen to try and avoid clipping the DTM "
+ "and to make the grid floor larger. *WARNING* This will change "
+ "clipping distances and the Blender grid floor, and will fit the "
+ "DTM in the viewport."
+ ),
+ name="Setup Blender Scene", default=True
+ )
+ # 2. Blender's default units are dimensionless. This option instructs
+ # Blender to change its unit's dimension to meters.
+ should_setup_units = bpy.props.BoolProperty(
+ description=(
+ "Set the Blender scene to use meters as its unit."
+ ),
+ name="Set Blender Units to Meters", default=True
+ )
+
+ def execute(self, context):
+ """Runs when the "Import HiRISE Terrain Model" button is pressed"""
+ filepath = bpy.path.ensure_ext(self.filepath, self.filename_ext)
+ # Create a BTerrain from the DTM
+ dtm = DTM(filepath, self.scaled_dtm_resolution)
+ BTerrain.new(dtm)
+
+ # Set up the Blender UI
+ if self.should_setup_units:
+ self._setup_units(context)
+ if self.should_setup_viewport:
+ self._setup_viewport(context)
+
+ return {"FINISHED"}
+
+ def _setup_units(self, context):
+ """Sets up the Blender scene for viewing the DTM"""
+ scene = bpy.context.scene
+
+ # Set correct units
+ scene.unit_settings.system = 'METRIC'
+ scene.unit_settings.scale_length = 1.0
+
+ return {'FINISHED'}
+
+ def _setup_viewport(self, context):
+ """Sets up the Blender screen to make viewing the DTM easier"""
+ screen = bpy.context.screen
+
+ # Fetch the 3D_VIEW Area
+ for area in screen.areas:
+ if area.type == 'VIEW_3D':
+ space = area.spaces[0]
+ # Adjust 3D View Properties
+ # TODO: Can these be populated more intelligently?
+ space.clip_end = 100000
+ space.grid_scale = 1000
+ space.grid_lines = 50
+
+ # Fly to a nice view of the DTM
+ self._view_dtm(context)
+
+ return {'FINISHED'}
+
+ def _view_dtm(self, context):
+ """Sets up the Blender screen to make viewing the DTM easier"""
+ screen = bpy.context.screen
+
+ # Fetch the 3D_VIEW Area
+ for area in screen.areas:
+ if area.type == 'VIEW_3D':
+ # Move the camera around in the viewport. This requires
+ # a context override.
+ for region in area.regions:
+ if region.type == 'WINDOW':
+ override = {
+ 'area': area,
+ 'region': region,
+ 'edit_object': bpy.context.edit_object
+ }
+ # Center View on DTM (SHORTCUT: '.')
+ bpy.ops.view3d.view_selected(override)
+ # Move to 'TOP' viewport (SHORTCUT: NUMPAD7)
+ bpy.ops.view3d.viewnumpad(override, type='TOP')
+
+ return {'FINISHED'}
diff --git a/io_convert_image_to_mesh_img/ui/terrainpanel.py b/io_convert_image_to_mesh_img/ui/terrainpanel.py
new file mode 100644
index 00000000..80dfe8e4
--- /dev/null
+++ b/io_convert_image_to_mesh_img/ui/terrainpanel.py
@@ -0,0 +1,133 @@
+# This file is a part of the HiRISE DTM Importer for Blender
+#
+# Copyright (C) 2017 Arizona Board of Regents on behalf of the Planetary Image
+# Research Laboratory, Lunar and Planetary Laboratory at the University of
+# Arizona.
+#
+# This program is free software: you can redistribute it and/or modify it
+# under the terms of the GNU General Public License as published by the Free
+# Software Foundation, either version 3 of the License, or (at your option)
+# any later version.
+#
+# This program is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
+# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+# for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program. If not, see <http://www.gnu.org/licenses/>.
+
+"""Blender panel for managing a DTM *after* it's been imported"""
+
+import bpy
+
+from ..mesh.terrain import BTerrain
+from ..mesh.dtm import DTM
+
+
+class TerrainPanel(bpy.types.Panel):
+ """Creates a Panel in the Object properites window for terrain objects"""
+ bl_label = "Terrain Model"
+ bl_idname = "OBJECT_PT_terrain"
+ bl_space_type = "PROPERTIES"
+ bl_region_type = "WINDOW"
+ bl_context = "object"
+
+ # Allow the user to specify a new resolution factor for reloading the
+ # terrain data at. This is useful because it allows the user to stage
+ # a scene with a low resolution terrain map, apply textures, modifiers,
+ # etc. and then increase the resolution to prepare for final rendering.
+ #
+ # Displaying this value as a percentage (0, 100] is an intuitive way
+ # for users to grasp what this value does. The DTM importer, however,
+ # wants to recieve a value between (0, 1]. This is obviously a
+ # straightforward conversion:
+ #
+ # f(x) = x / 100
+ #
+ # But this conversion should happen here, in the terrain panel, rather
+ # than in the DTM importing utility itself. We can't pass get/set
+ # functions to the property itself because they result in a recursion
+ # error. Instead, we use another, hidden, property to store the scaled
+ # resolution.
+ bpy.types.Object.dtm_resolution = bpy.props.FloatProperty(
+ subtype="PERCENTAGE",
+ name="New Resolution",
+ description=(
+ "Percentage scale for terrain model resolution. 100\% loads the "
+ "model at full resolution (i.e. one vertex for each post in the "
+ "original terrain model) and is *MEMORY INTENSIVE*. Downsampling "
+ "uses Nearest Neighbors. The downsampling algorithm may need to "
+ "alter the resolution you specify here to ensure it results in a "
+ "whole number of vertices. If it needs to alter the value you "
+ "specify, you are guaranteed that it will shrink it (i.e. "
+ "decrease the DTM resolution."
+ ),
+ min=1.0, max=100.0, default=10.0
+ )
+ bpy.types.Object.scaled_dtm_resolution = bpy.props.FloatProperty(
+ options={'HIDDEN'},
+ name="Scaled Terrain Model Resolution",
+ get=(lambda self: self.dtm_resolution / 100.0)
+ )
+
+ @classmethod
+ def poll(cls, context):
+ return context.object.get("IS_TERRAIN", False)
+
+ def draw(self, context):
+ obj = context.active_object
+ layout = self.layout
+
+ # User Controls
+ layout.prop(obj, 'dtm_resolution')
+ layout.operator("terrain.reload")
+
+ # Metadata
+ self.draw_metadata_panel(context)
+
+ def draw_metadata_panel(self, context):
+ """Display some metadata about the DTM"""
+ obj = context.active_object
+ layout = self.layout
+
+ metadata_panel = layout.box()
+
+ dtm_resolution = metadata_panel.row()
+ dtm_resolution.label('Current Resolution: ')
+ dtm_resolution.label('{:9,.2%}'.format(
+ obj['DTM_RESOLUTION']
+ ))
+
+ mesh_scale = metadata_panel.row()
+ mesh_scale.label('Current Scale: ')
+ mesh_scale.label('{:9,.2f} m/post'.format(
+ obj['MESH_SCALE']
+ ))
+
+ dtm_scale = metadata_panel.row()
+ dtm_scale.label('Original Scale: ')
+ dtm_scale.label('{:9,.2f} m/post'.format(
+ obj['MAP_SCALE']
+ ))
+
+ return {'FINISHED'}
+
+
+class ReloadTerrain(bpy.types.Operator):
+ """Button for reloading the terrain mesh at a new resolution."""
+ bl_idname = "terrain.reload"
+ bl_label = "Reload Terrain"
+
+ def execute(self, context):
+ # Reload the terrain
+ obj = context.object
+ path = obj['PATH']
+
+ scaled_dtm_resolution = obj.scaled_dtm_resolution
+
+ # Reload BTerrain with new DTM
+ dtm = DTM(path, scaled_dtm_resolution)
+ BTerrain.reload(obj, dtm)
+
+ return {"FINISHED"}