Source code for ladybug_display.visualization

# coding=utf-8
from __future__ import division
import os
import io
import json
import collections
try:  # check if we are in IronPython
    import cPickle as pickle
except ImportError:  # wea are in cPython
    import pickle

from ladybug_geometry.geometry3d import Vector3D, Point3D, Plane
from ladybug_geometry.bounding import bounding_box

from ._base import _VisualizationBase
from .analysis import GEOMETRY_UNION, AnalysisGeometry, \
    VisualizationData, VisualizationMetaData
from .context import DISPLAY_UNION, ContextGeometry
import ladybug_display.svg as svg


[docs] class VisualizationSet(_VisualizationBase): """A visualization set containing analysis and context geometry to be visualized. Args: identifier: Text string for a unique object ID. Must be less than 100 characters and not contain spaces or special characters. geometry: A list of AnalysisGeometry and ContextGeometry objects to display in the visualization. Each geometry object will typically be translated to its own layer within the interface that renders the VisualizationSet. units: Text for the units system in which the visualization geometry exists. If None, the geometry will always be assumed to be in the current units system of the display interface. (Default: None). Choose from the following: * Meters * Millimeters * Feet * Inches * Centimeters Properties: * identifier * display_name * geometry * min_point * max_point * min_point_with_legend * max_point_with_legend * units * user_data """ __slots__ = ('_geometry', '_min_point', '_max_point', '_min_point_with_legend', '_max_point_with_legend', '_units') UNITS = ('Meters', 'Millimeters', 'Feet', 'Inches', 'Centimeters') GEOMETRY_UNION = GEOMETRY_UNION DISPLAY_UNION = DISPLAY_UNION ANALYSIS_CLASSES = (AnalysisGeometry, VisualizationData, VisualizationMetaData) VIEW_MAP = { 'Top': Plane(n=Vector3D(0, 0, 1)), 'Left': Plane(n=Vector3D(1, 0, 0)), 'Right': Plane(n=Vector3D(-1, 0, 0)), 'Front': Plane(n=Vector3D(0, 1, 0)), 'Back': Plane(n=Vector3D(0, -1, 0)), 'NE': Plane(n=Vector3D(1, 1, 1)), 'NW': Plane(n=Vector3D(-1, 1, 1)), 'SE': Plane(n=Vector3D(1, -1, 1)), 'SW': Plane(n=Vector3D(-1, -1, 1)) } def __init__(self, identifier, geometry, units=None): """Initialize VisualizationSet.""" _VisualizationBase.__init__(self, identifier) # process the identifier self.geometry = geometry self.units = units self._min_point = None self._max_point = None self._min_point_with_legend = None self._max_point_with_legend = None
[docs] @classmethod def from_dict(cls, data): """Create an VisualizationSet from a dictionary. Args: data: A python dictionary in the following format .. code-block:: python { "type": "VisualizationSet", "identifier": "", # unique object identifier "geometry": [] # list of AnalysisGeometry and ContextGeometry objects } """ # check the type key assert data['type'] == 'VisualizationSet', \ 'Expected VisualizationSet, Got {}.'.format(data['type']) # re-serialize the context and analysis geometry geos = [] for geo_data in data['geometry']: if geo_data['type'] == 'AnalysisGeometry': geos.append(AnalysisGeometry.from_dict(geo_data)) else: geos.append(ContextGeometry.from_dict(geo_data)) new_obj = cls(data['identifier'], geos) if 'units' in data and data['units'] is not None: new_obj.units = data['units'] if 'display_name' in data and data['display_name'] is not None: new_obj.display_name = data['display_name'] if 'user_data' in data and data['user_data'] is not None: new_obj.user_data = data['user_data'] return new_obj
[docs] @classmethod def from_file(cls, vis_set_file): """Initialize a VisualizationSet from a JSON or pkl file, auto-sensing the type. Args: VisualizationSet: Path to either a VisualizationSet JSON or pkl file. """ # sense the file type from the first character to avoid maxing memory with JSON # this is needed since queenbee overwrites all file extensions with io.open(vis_set_file, encoding='utf-8') as inf: try: first_char = inf.read(1) second_char = inf.read(1) is_json = True if first_char == '{' or second_char == '{' else False except UnicodeDecodeError: # definitely a pkl file is_json = False # load the file using either JSON pathway or pkl if is_json: return cls.from_json(vis_set_file) return cls.from_pkl(vis_set_file)
[docs] @classmethod def from_json(cls, json_file): """Initialize a VisualizationSet from a JSON file. Args: json_file: Path to VisualizationSet JSON file. """ assert os.path.isfile(json_file), 'Failed to find %s' % json_file with io.open(json_file, encoding='utf-8') as inf: data = json.load(inf) return cls.from_dict(data)
[docs] @classmethod def from_pkl(cls, pkl_file): """Initialize a Model from a pkl file. Args: pkl_file: Path to pkl file. """ assert os.path.isfile(pkl_file), 'Failed to find %s' % pkl_file with open(pkl_file, 'rb') as inf: data = pickle.load(inf) return cls.from_dict(data)
[docs] @classmethod def from_single_analysis_geo( cls, identifier, geometry, values, legend_parameters=None, data_type=None, unit=None): """Create an VisualizationSet from a raw geometry object and a list of values. Args: identifier: Text string for a unique object ID. Must be less than 100 characters and not contain spaces or special characters. geometry: A list of ladybug-geometry objects that is aligned with the values. The length of this list should usually be equal to the total number of values in each data_set, indicating that each geometry gets a single color. Alternatively, if all of the geometry objects are meshes, the number of values in the data can be equal to the total number of faces across the meshes or the total number of vertices across the meshes. values: A list of numerical values that will be used to generate the visualization colors. legend_parameters: An Optional LegendParameters object to override default parameters of the legend. None indicates that default legend parameters will be used. (Default: None). data_type: Optional DataType from the ladybug datatype subpackage (ie. Temperature()) , which will be used to assign default legend properties. If None, the legend associated with this object will contain no units unless a unit below is specified. (Default: None). unit: Optional text string for the units of the values. (ie. "C"). If None or empty, the default units of the data_type will be used. If no data type is specified in this case, this will simply be an empty string. (Default: None). """ viz_data = VisualizationData(values, legend_parameters, data_type, unit) a_geo = AnalysisGeometry('{}_Geometry'.format(identifier), geometry, [viz_data]) return cls(identifier, (a_geo,))
@property def geometry(self): """Get or set a tuple of AnalysisGeometry and ContextGeometry objects.""" return self._geometry @geometry.setter def geometry(self, value): assert isinstance(value, (list, tuple)), 'Expected list or tuple for' \ ' VisualizationSet geometry. Got {}.'.format(type(value)) if not isinstance(value, tuple): value = tuple(value) for geo in value: self._check_geometry(geo) self._geometry = value @property def min_point(self): """A Point3D for the minimum bounding box vertex around all of the geometry.""" if self._min_point is None: self._calculate_min_max() return self._min_point @property def max_point(self): """A Point3D for the maximum bounding box vertex around all of the geometry.""" if self._max_point is None: self._calculate_min_max() return self._max_point @property def min_point_with_legend(self): """A Point3D for the minimum around all geometry, including 3D legends.""" if self._min_point_with_legend is None: self._calculate_min_max(True) return self._min_point_with_legend @property def max_point_with_legend(self): """A Point3D for the maximum around all geometry, including 3D legends.""" if self._max_point_with_legend is None: self._calculate_min_max(True) return self._max_point_with_legend @property def units(self): """Get or set Text for the units system in which the geometry exists.""" return self._units @units.setter def units(self, value): if value is not None: value = value.title() assert value in self.UNITS, '{} is not supported as a units system. ' \ 'Choose from the following: {}'.format(value, self.UNITS) self._units = value
[docs] def add_vis_set(self, vis_set): """Add all geometry objects of another VisualizationSet to this one. Args: vis_set: A VisualizationData object to be added to this AnalysisGeometry. """ for geo in vis_set.geometry: self.add_geometry(geo)
[docs] def add_geometry(self, geometry, insert_index=None): """Add a ContextGeometry or AnalysisGeometry object to this VisualizationSet. Args: geometry: A ContextGeometry or AnalysisGeometry object to be added to this VisualizationSet. insert_index: An integer for the index at which the data should be inserted. If None, the data will be appended to the end. (Default: None). """ self._check_geometry(geometry) if insert_index is None: self._geometry = self._geometry + (geometry,) else: geos_list = list(self._geometry) geos_list.insert(insert_index, geometry) self._geometry = tuple(geos_list) self._min_point = None self._max_point = None self._min_point_with_legend = None self._max_point_with_legend = None
[docs] def remove_geometry(self, geo_index): """Remove a geometry object from this VisualizationSet. Args: geo_index: An integer for the geometry index to be removed. """ assert geo_index < len(self._geometry), 'geo_index ({}) must be less than ' \ 'the number of items in the data_sets ({}).'.format( geo_index, len(self._geometry)) geos_list = list(self._geometry) geos_list.pop(geo_index) self._geometry = tuple(geos_list) self._min_point = None self._max_point = None self._min_point_with_legend = None self._max_point_with_legend = None
[docs] def check_duplicate_identifiers(self, raise_exception=True, detailed=False): """Check that there are no duplicate geometry object identifiers in the set. Args: raise_exception: Boolean to note whether a ValueError should be raised if duplicate identifiers are found. (Default: True). detailed: Boolean for whether the returned object is a detailed list of dicts with error info or a string with a message. (Default: False). Returns: A string with the message or a list with a dictionary if detailed is True. """ detailed = False if raise_exception else detailed obj_id_iter = (obj.identifier for obj in self.geometry) dup = [t for t, c in collections.Counter(obj_id_iter).items() if c > 1] if len(dup) != 0: if detailed: err_list = [] for dup_id in dup: msg = 'There is a duplicated geometry identifier: {}'.format(dup_id) dup_dict = { 'type': 'ValidationError', 'element_type': 'Geometry', 'element_id': dup_id, 'element_name': dup_id, 'message': msg } err_list.append(dup_dict) return err_list msg = 'The following duplicated Geometry identifiers were found:\n{}'.format( '\n'.join(dup)) if raise_exception: raise ValueError(msg) return msg return [] if detailed else ''
[docs] def graphic_container(self, geo_index=0, data_index=None, min_point=None, max_point=None): """Get a Ladybug GraphicContainer object, which can be used to draw legends. Args: geo_index: Integer for the index of the geometry for which a GraphicContainer will be returned. Note that this index must refer to an analysis geometry in order to produce a valid graphic container. (Default: 0). data_index: Integer for the index of the data set for which a GraphicContainer will be returned. If None, the active_data set will be used. (Default: None). min_point: An optional Point3D to denote the minimum bounding box for the graphic container. If None, this object's own min_point will be used, which corresponds to the bounding box around the geometry. (Default: None). max_point: An optional Point3D to denote the maximum bounding box for the graphic container. If None, this object's own max_point will be used, which corresponds to the bounding box around the geometry. (Default: None). """ # check to be sure that there is analysis geometry geo_obj = self.geometry[geo_index] assert isinstance(geo_obj, AnalysisGeometry), 'VisualizationSet geo_index ' \ 'must refer to an AnalysisGeometry in order to use ' \ 'graphic_container method.' # ensure that min and max points always make sense min_point = self.min_point if min_point is None else min_point max_point = self.max_point if max_point is None else max_point # return the Graphic Container for the correct data set data_index = geo_obj.active_data if data_index is None else data_index dat_set = geo_obj.data_sets[data_index] return dat_set.graphic_container(min_point, max_point)
[docs] def move(self, moving_vec): """Move this VisualizationSet along a vector. Args: moving_vec: A ladybug_geometry Vector3D with the direction and distance to move the VisualizationSet. """ for geo in self.geometry: geo.move(moving_vec) self._min_point = None self._max_point = None self._min_point_with_legend = None self._max_point_with_legend = None
[docs] def rotate_xy(self, angle, origin): """Rotate this VisualizationSet counterclockwise in the world XY plane. Args: angle: An angle in degrees. origin: A ladybug_geometry Point3D for the origin around which the object will be rotated. """ for geo in self.geometry: geo.rotate_xy(angle, origin) self._min_point = None self._max_point = None self._min_point_with_legend = None self._max_point_with_legend = None
[docs] def scale(self, factor, origin=None): """Scale this VisualizationSet by a factor from an origin point. Args: factor: A number representing how much the object should be scaled. origin: A ladybug_geometry Point3D representing the origin from which to scale. If None, it will be scaled from the World origin (0, 0, 0). """ for geo in self._geometry: geo.scale(factor, origin) self._min_point = None self._max_point = None self._min_point_with_legend = None self._max_point_with_legend = None
[docs] def convert_to_units(self, units): """Convert all of the geometry in this VisualizationSet to certain units. This involves scaling the geometry and changing the VisualizationSet's units property. Args: units: Text for the units to which the VisualizationSet geometry should be converted. Choose from the following: * Meters * Millimeters * Feet * Inches * Centimeters """ if self.units != units: scale_fac1 = self._conversion_factor_to_meters(self.units) scale_fac2 = self._conversion_factor_to_meters(units) scale_fac = scale_fac1 / scale_fac2 self.scale(scale_fac) self.units = units
[docs] def to_dict(self): """Get VisualizationSet as a dictionary.""" base = { 'type': 'VisualizationSet', 'identifier': self.identifier, 'geometry': [geo_obj.to_dict() for geo_obj in self.geometry] } if self._units is not None: base['units'] = self.units if self._display_name is not None: base['display_name'] = self.display_name if self.user_data is not None: base['user_data'] = self.user_data return base
[docs] def to_json(self, name, folder, indent=None): """Write VisualizationSet to JSON. Args: name: A text string for the name of the JSON file. folder: A text string for the directory where the JSON will be written. indent: A positive integer to set the indentation used in the resulting JSON file. (Default: None). """ # create dictionary from the VisualizationSet vs_dict = self.to_dict() # set up a name and folder for the JSON nl = name.lower() file_name = name if nl.endswith('.vsf') or nl.endswith('.json') \ else '{}.vsf'.format(name) if not os.path.isdir(folder): os.makedirs(folder) vs_file = os.path.join(folder, file_name) # write JSON with open(vs_file, 'w') as fp: json.dump(vs_dict, fp, indent=indent) return vs_file
[docs] def to_pkl(self, name, folder): """Write VisualizationSet to compressed pickle file (pkl). Args: name: A text string for the name of the pickle file. folder: A text string for the directory where the pickle file will be written. """ # create dictionary from the VisualizationSet vs_dict = self.to_dict() # set up a name and folder for the pkl nl = name.lower() file_name = name if nl.endswith('.vsf') or nl.endswith('.pkl') \ else '{}.vsf'.format(name) if not os.path.isdir(folder): os.makedirs(folder) vs_file = os.path.join(folder, file_name) # write the Model dictionary into a file with open(vs_file, 'wb') as fp: pickle.dump(vs_dict, fp) return vs_file
[docs] def to_svg(self, width=800, height=600, margin=None, interactive=False, render_3d_legend=False, render_2d_legend=False, view='Top'): """Get this VisualizationSet as an editable SVG object. Casting the SVG object to string will give the file contents of a SVG. All contents of the VisualizationSet will automatically scaled to fit within the specified pixel width and height of the SVG Args: width: The screen width in pixels. (Default: 800). height: The screen height in pixels. (Default: 600). margin: An optional number to set the size of the margins around the base graphic in the final image. If None, this is automatically set to be 2% of whatever the constraining dimension is (either width or height). (Default: None). interactive: A boolean to note whether AnalysisGeometry in the VisualizationSet should be rendered with interactive hover effects (True) or they should be rendered as static (False). If hover effects are included, then objects that are a part of AnalysisGeometries will be highlighted with a thick black border when hovered over and hover text will appear with the value associated with the geometry. (Default: False). render_3d_legend: Boolean to note whether a 3D version of the legend for any AnalysisGeometry should be included in the SVG (following the 3D dimensions specified in the LegendParameters). render_2d_legend: Boolean to note whether a 2D version of the legend for any AnalysisGeometry should be included in the SVG (following the 2D dimensions specified in the LegendParameters). view: An optional text string for the view for which the SVG will be generated. This can also be a ladybug-geometry Plane object for the plane in which an axonometric view will be generated. Choose from the common options below when using a text string. * Top * Left * Right * Front * Back * NE * NW * SE * SW """ # compute the scene width and height if margin is None: scene_width, scene_height = width * 0.96, height * 0.96 else: scene_width, scene_height = width - (2 * margin), height - (2 * margin) default_leg_pos = [0, 10, 50] # project the geometry into a plane if requested vis_geometry = [geo.duplicate() for geo in self.geometry if not geo.hidden] if view != 'Top' and view != Plane(): if isinstance(view, str): try: view = self.VIEW_MAP[view] except KeyError: msg = 'Unrecognized view type "{}". Choose from: {}'.format( view, ' '.join(list(self.VIEW_MAP.keys()))) raise ValueError(msg) else: assert isinstance(view, Plane), 'Input view must be a string or ' \ 'Plane. Got {}.'.format(type(view)) for geo in vis_geometry: geo.project_2d(view) geo.rotate_xy(180, Point3D()) # compute the bounding box dimensions around all of the VisualizationSet geometry if render_3d_legend: min_pt, max_pt = self.min_point_with_legend, self.max_point_with_legend else: min_pt, max_pt = self.min_point, self.max_point move_vec = Vector3D(-min_pt.x, -max_pt.y) x_dim, y_dim = max_pt.x - min_pt.x, max_pt.y - min_pt.y x_factor, y_factor = scene_width / x_dim, scene_height / y_dim scale_fac = min(x_factor, y_factor) if scale_fac == x_factor: # center the geometry in the Y dimension scene_height = y_dim * scale_fac else: scene_width = x_dim * scale_fac center_vec = Vector3D((width - scene_width) / 2, -(height - scene_height) / 2) # transform all of the visualization set geometry to be in the lower quadrant svg_elements = [] for geo in reversed(vis_geometry): geo.move(move_vec) geo.scale(scale_fac) geo.move(center_vec) if isinstance(geo, AnalysisGeometry): svg_data = geo.to_svg(interactive=interactive, render_3d_legend=render_3d_legend, render_2d_legend=render_2d_legend, default_leg_pos=default_leg_pos) default_leg_pos = list(svg_data.elements[-1].content) else: svg_data = geo.to_svg() svg_elements.extend(svg_data.elements) # combine everything into a final SVG object canvas = svg.SVG(width=width, height=height) canvas.elements = svg_elements return canvas
def _check_geometry(self, geo): """Check that the geometry object is valid.""" assert isinstance(geo, (AnalysisGeometry, ContextGeometry)), 'Expected ' \ 'AnalysisGeometry or ContextGeometry for VisualizationSet geometry. ' \ 'Got {}.'.format(type(geo)) def _calculate_min_max(self, with_legend=False): """Calculate maximum and minimum Point3D for this object.""" all_geo = [] for geo_obj in self.geometry: if with_legend and isinstance(geo_obj, AnalysisGeometry): all_geo.append(geo_obj.min_point_with_legend) all_geo.append(geo_obj.max_point_with_legend) else: all_geo.append(geo_obj.min_point) all_geo.append(geo_obj.max_point) if len(all_geo) != 0: if with_legend: self._min_point_with_legend, self._max_point_with_legend = \ bounding_box(all_geo) else: self._min_point, self._max_point = bounding_box(all_geo) def _conversion_factor_to_meters(self, units): """Get the conversion factor to meters based on input units. Args: units: Text for the units. Returns: A number for the conversion factor, which should be multiplied by all distance units taken from Rhino geometry in order to convert them to meters. """ if units == 'Meters': return 1.0 elif units == 'Millimeters': return 0.001 elif units == 'Feet': return 0.305 elif units == 'Inches': return 0.0254 elif units == 'Centimeters': return 0.01 else: raise ValueError( 'You are kidding me! What units are you using? {}?\n' 'Please use one of the following: {}'.format(units, ' '.join(self.UNITS)) )
[docs] def ToString(self): """Overwrite .NET ToString.""" return self.__repr__()
def __copy__(self): new_geo_objs = tuple(obj.duplicate() for obj in self.geometry) new_obj = VisualizationSet(self.identifier, new_geo_objs, self.units) new_obj._display_name = self._display_name new_obj._user_data = None if self.user_data is None else self.user_data.copy() return new_obj def __len__(self): """Return number of geometries on the object.""" return len(self.geometry) def __getitem__(self, key): """Return one of the geometries.""" return self.geometry[key] def __iter__(self): """Iterate through the geometries.""" return iter(self.geometry) def __repr__(self): """VisualizationSet representation.""" return 'Visualization Set: {}'.format(self.display_name)