"""An extension of VTK PolyData objects.
The purpose of creating these extensions is to add metadata to object itself. This will
work fine for the purpose of exporting the geometries and finding the type for each
object but it will not be useful for passing them to the exported VTK file itself.
For that purpose we should start to look into FieldData:
https://lorensen.github.io/VTKExamples/site/Cxx/PolyData/FieldData/
"""
import pathlib
import vtk
import warnings
from enum import Enum
from typing import Dict, Union, List, Tuple
from ladybug.color import Color
from .legend_parameter import LegendParameter, ColorSets
from .vtkjs.schema import DataSetProperty, DataSet, DisplayMode, DataSetMapper
from honeybee.face import Face
from honeybee.aperture import Aperture
from honeybee.door import Door
from honeybee.shade import Shade
[docs]class DataSetNames(Enum):
"""Valid ModelDataset names."""
wall = 'wall'
aperture = 'aperture'
shade = 'shade'
door = 'door'
floor = 'floor'
roofceiling = 'roofceiling'
airboundary = 'airboundary'
grid = 'grid'
[docs]class VTKWriters(Enum):
"""Vtk writers."""
legacy = 'vtk'
binary = 'vtp'
[docs]class ImageTypes(Enum):
"""Supported image types."""
png = 'png'
jpg = 'jpg'
ps = 'ps'
tiff = 'tiff'
bmp = 'bmp'
pnm = 'pnm'
[docs]class RadialSensor:
"""Object to customize the triangle to be created at each sensor of the grid.
This method is only used when radial-grid is selected from grid options.
Args:
angle: A value in degrees to define the internal angle of the triangle.
Defaults to 45 degrees.
radius: Radial height of the triangle. If not provided, the magnitude of
direction vector of the sensor will be used. Defaults to None.
"""
def __init__(self, angle: int = 45, radius: float = None) -> None:
self._angle = angle
self._radius = radius
@property
def angle(self):
return self._angle
@angle.setter
def angle(self, value):
self._angle = value
@property
def radius(self):
return self._radius
@radius.setter
def radius(self, value):
self._radius = value
[docs]class DataFieldInfo:
"""Data info for metadata that is added to Polydata.
This object hosts information about the data that is added to polydata.
This object consists name, min and max values in the data, and the color
theme to be used in visualization of the data.
Args:
name: A string representing the name of for data.
range: A tuple of min, max values as either integers or floats.
Defaults to None which will create a range of minimum and maximum
values in the data.
colorset: A ColorSet object that defines colors for the legend.
Defaults to Ecotect colorset.
per_face : A Boolean to indicate if the data is per face or per point. In
most cases except for sensor points that are loaded as sensors the data
are provided per face.
lower_threshold: Lower end of the threshold range beyond which the polydata
will be filtered. If None, the lower threshold will be infinite. Defaults
to None.
upper_threshold: Upper end of the threshold range beyond which the polydata
will be filtered. If None, the upper threshold will be infinite. Defaults
to None.
"""
def __init__(self, name: str = 'default', range: Tuple[float, float] = None,
colorset: ColorSets = ColorSets.ecotect, per_face: bool = True,
lower_threshold: float = None, upper_threshold: float = None) -> None:
self.name = name
self._range = range
self.per_face = per_face
self._legend_param = LegendParameter(
name=name, colorset=colorset, auto_range=range)
self.lower_threshold = lower_threshold
self.upper_threshold = upper_threshold
@property
def legend_parameter(self) -> LegendParameter:
"""Legend associated with the DataFieldInfo object."""
return self._legend_param
@property
def range(self) -> Tuple[float, float]:
"""Range is a tuple of minimum and maximum values.
If these minimum and maximum values are not provided, they are calculated
automatically. In such a case, the minimum and maximum values in the data are
used.
"""
return self._range
@property
def lower_threshold(self) -> float:
"""Lower threshold value."""
return self._lower_threshold
@lower_threshold.setter
def lower_threshold(self, value: float) -> None:
"""Lower threshold value."""
self._lower_threshold = value
@property
def upper_threshold(self) -> float:
"""Upper threshold value."""
return self._upper_threshold
@upper_threshold.setter
def upper_threshold(self, value: float) -> None:
"""Upper threshold value."""
self._upper_threshold = value
def _get_data_range(
name: str,
data_range: List[Union[float, int]],
array: Union[vtk.vtkFloatArray, vtk.vtkIntArray]) -> List[Union[float, int]]:
"""Calculate data range for data based on data array and user provided legend range.
Args:
name: Name of data (e.g. Useful Daylight Autonomy.)
data_range: A list of numbers.
array: A vtk float array or a vtk int array.
Returns:
A list of min and max values to be used as a range for the data.
"""
# calculate range based on the data
if not isinstance(array, vtk.vtkStringArray):
auto_range = array.GetRange()
if not data_range:
warnings.warn(
f'In data {name.capitalize()}, since min and max'
' values of legend are not provided, those values will be auto'
' calculated based on data. \n'
)
return tuple(auto_range)
min, max = data_range
if min == None and max == None:
warnings.warn(
f'In data {name.capitalize()}, since min and max'
' values of legend are not provided, those values will be auto'
' calculated based on data. \n'
)
return tuple(auto_range)
elif min == None and max:
warnings.warn(
f'In data {name.capitalize()}, since min'
' value of the legend is not provided, that value will be auto'
' calculated based on data. \n'
)
return (auto_range[0], max)
elif min and max == None:
warnings.warn(
f'In data {name.capitalize()}, since max'
' value of the legend is not provided, that value will be auto'
' calculated based on data. \n'
)
return (min, auto_range[1])
elif min == 0 and max == 0:
raise ValueError(
f'In data {name.capitalize()}, min and max values'
' of legend cannot be both 0. \n'
)
elif isinstance(min, (float, int)) and isinstance(max, (float, int)):
if min >= max:
raise ValueError(
f'In data {name.capitalize()} Min value cannot be greater'
' than the max value.')
if min == max:
raise ValueError(
f'In data {name.capitalize()} Min and max values cannot'
' be the same.')
else:
return (min, max)
else:
return tuple(data_range)
[docs]class PolyData(vtk.vtkPolyData):
"""A thin wrapper around vtk.vtkPolyData.
PolyData has additional fields for metadata information.
"""
def __init__(self) -> None:
super().__init__()
self.identifier = None
self.name = None
self.type = None
self.boundary = None
self.construction = None
self.modifier = None
self._fields = {} # keep track of information for each data field.
@ staticmethod
def _resolve_array_type(data):
if isinstance(data, float):
return vtk.vtkFloatArray()
elif isinstance(data, int):
return vtk.vtkIntArray()
elif isinstance(data, str):
return vtk.vtkStringArray()
else:
raise ValueError(f'Unsupported input data type: {type(data)}')
@ property
def data_fields(self) -> Dict[str, DataFieldInfo]:
"""Get data fields for this Polydata."""
return self._fields
[docs] def add_data(self, data: List, name, *, cell=True, colors=None,
data_range=None, lower_threshold: float = None, upper_threshold: float = None) -> None:
"""Add a list of data to a vtkPolyData.
Data can be added to cells or points. By default the data will be added to cells.
Args:
data: A list of values. The length of the data should match the length of
DataCells or DataPoints in Polydata.
name: Name of data (e.g. Useful Daylight Autonomy.)
cell: A Boolean to indicate if the data is per cell or per point. In
most cases except for sensor points that are loaded as sensors the data
are provided per cell.
colors: A Colors object that defines colors for the legend.
data_range: A list with two values for minimum and maximum values for legend
parameters.
lower_threshold: Lower end of the threshold range for the data.
Data beyond this threshold will be filtered. If not specified, the lower
threshold will be infinite. Defaults to None.
upper_threshold: Upper end of the threshold range for the data.
Data beyond this threshold will be filtered. If not specified, the upper
threshold will be infinite. Defaults to None.
"""
assert name not in self._fields, \
f'A data filed by name "{name}" already exist. Try a different name.'
if isinstance(data[0], (list, tuple)):
values = self._resolve_array_type(data[0][0])
values.SetNumberOfComponents(len(data[0]))
values.SetNumberOfTuples(len(data))
iterator = True
else:
values = self._resolve_array_type(data[0])
iterator = False
if name:
values.SetName(name)
if iterator:
# NOTE: This is my (mostapha's) understanding from the original code for
# tuple data. This needs to be tested.
for d in data:
values.InsertNextValue(*d)
else:
for d in data:
values.InsertNextValue(d)
if cell:
self.GetCellData().AddArray(values)
else:
self.GetPointData().AddArray(values)
self.Modified()
# set data range
data_range = _get_data_range(name, data_range, values)
# set colors
if not colors:
colors = ColorSets.ecotect
self._fields[name] = DataFieldInfo(
name, data_range, colors, cell, lower_threshold, upper_threshold)
# if it's a string array don't publish the legend
if isinstance(values, vtk.vtkStringArray):
self._fields[name].legend_parameter.hide_legend = True
[docs] def color_by(self, name: str, cell=True) -> None:
"""Set the name for active data that should be used to color PolyData."""
assert name in self._fields, \
f'{name} is not a valid data field for this PolyData. Available ' \
f'data fields are: {list(self._fields)}'
if cell:
self.GetCellData().SetActiveScalars(name)
else:
self.GetPointData().SetActiveScalars(name)
self.Modified()
[docs] def to_vtk(self, target_folder, name, writer):
"""Write to a VTK file.
The file extension will be set to vtk for ASCII format and vtp for binary format.
"""
return _write_to_file(self, target_folder, name, writer)
[docs] def to_folder(self, target_folder='.'):
"""Write data to a folder with a JSON meta file.
This method generates a folder that includes a JSON meta file along with all the
binary arrays written as standalone binary files.
The generated format can be used by vtk.js using the reader below
https://kitware.github.io/vtk-js/examples/HttpDataSetReader.html
Args:
target_folder: Path to target folder. Default: .
"""
return _write_to_folder(self, target_folder)
def _get_metadata(self, hb_object: Union[Face, Aperture, Shade]) -> Dict[str, list]:
"""Extract metadata from a honeybee object and get it as a dictionary.
This private method will extract properties such as display name,
boundary condition, construction display name, and modifier display name from
a honeybee object and will return a dictionary that has property name as keys
and a list of property names as values. The length of the list will be
equal to the number of cells in the Polydata object.
"""
# extracting metadata and setting attributes
if isinstance(hb_object, Face):
self.type = hb_object.type.name
elif isinstance(hb_object, Aperture):
self.type = 'Aperture'
elif isinstance(hb_object, Shade):
self.type = 'Shade'
elif isinstance(hb_object, Door):
self.type = 'Door'
self.identifier = hb_object.identifier
self.name = hb_object.display_name
if self.type != 'Shade':
self.boundary = hb_object.boundary_condition.ToString()
self.construction = hb_object.properties.energy.construction.display_name
self.modifier = hb_object.properties.radiance.modifier.display_name
# number of cells in polydata
num_of_cells = self.GetNumberOfCells()
# creating a dictionary with metadata name : List[metadata value] structure
if self.type == 'Shade':
name = [self.name] * num_of_cells
construction = [self.construction] * num_of_cells
modifier = [self.modifier] * num_of_cells
metadata = {
"Name": name,
"Construction": construction,
"Modifier": modifier
}
else:
name = [self.name] * num_of_cells
boundary = [self.boundary] * num_of_cells
construction = [self.construction] * num_of_cells
modifier = [self.modifier] * num_of_cells
metadata = {
"Name": name,
"Boundary": boundary,
"Construction": construction,
"Modifier": modifier
}
return metadata
def _add_metadata(self, metadata: Dict[str, list]) -> None:
"""Add metadata to Polydata.
Args:
metadata: A dictionary generated from _get_metadata method.
"""
for key, value in metadata.items():
self.add_data(value, key, data_range=[0, 0])
def __repr__(self) -> Tuple[str]:
return (
f'Name: {self.name} |'
f' Boundary: {self.boundary} |'
f' Construction: {self.construction} |'
f' Modifier: {self.modifier}'
)
[docs]class JoinedPolyData(vtk.vtkAppendPolyData):
"""A thin wrapper around vtk.vtkAppendPolyData."""
def __init__(self) -> None:
super().__init__()
[docs] @ classmethod
def from_polydata(cls, polydata: List[PolyData]):
"""Join several polygonal datasets.
This function merges several polygonal datasets into a single polygonal datasets.
All geometry is extracted and appended, but point and cell attributes (i.e.,
scalars, vectors, normals) are extracted and appended only if all datasets have
the point and/or cell attributes available. (For example, if one dataset has
point scalars but another does not, point scalars will not be appended.)
"""
joined_polydata = cls()
for vtk_polydata in polydata:
joined_polydata.AddInputData(vtk_polydata)
joined_polydata.Update()
return joined_polydata
[docs] def append(self, polydata: PolyData) -> None:
"""Append a new polydata to current data."""
self.AddInputData(polydata)
self.Update()
[docs] def extend(self, polydata: List[PolyData]) -> None:
"""Extend a list of new polydata to current data."""
for data in polydata:
self.AddInputData(data)
self.Update()
[docs] def to_vtk(self, target_folder, name, writer):
"""Write to a VTK file.
The file extension will be set to vtk for ASCII format and vtp for binary format.
"""
return _write_to_file(self, target_folder, name, writer)
[docs] def to_folder(self, target_folder='.'):
"""Write data to a folder with a JSON meta file.
This method generates a folder that includes a JSON meta file along with all the
binary arrays written as standalone binary files.
The generated format can be used by vtk.js using the reader below
https://kitware.github.io/vtk-js/examples/HttpDataSetReader.html
Args:
target_folder: Path to target folder. Default: .
"""
return _write_to_folder(self, target_folder)
[docs]class ModelDataSet:
"""A dataset object in honeybee VTK model.
This data set holds the PolyData objects as well as representation information
for those PolyData. All the objects in ModelDataSet will have the same
representation.
"""
def __init__(self, name, data: List[PolyData] = None, color: Color = None,
display_mode: DisplayMode = DisplayMode.Shaded) -> None:
self.name = name
self.data = data or []
self.color = color
self.display_mode = display_mode
self.color_by = None
@ property
def fields_info(self) -> dict:
return {} if not self.data else self.data[0]._fields
@ property
def active_field_info(self) -> DataFieldInfo:
"""Get information for active field info.
It will be the field info for the field that is set in color_by. If color_by
is not set the first field will be used. If no field is available a default
field will be generated.
"""
info = self.fields_info
color_by = self.color_by
if not info:
return DataFieldInfo()
if not color_by:
return next(iter(info.values()))
return info[color_by]
[docs] def add_data_fields(
self, data: List[List], name: str, per_face: bool = True, colors=None,
data_range=None, lower_threshold: float = None, upper_threshold: float = None):
"""Add data fields to PolyData objects in this dataset.
Use this method to add data like temperature or illuminance values to PolyData
objects. The length of the input data should match the length of the data in
DataSet.
Args:
data: A list of list of values. There should be a list per data in DataSet.
The order of data should match the order of data in DataSet. You can
use data.identifier to match the orders before assigning them to DataSet.
name: Name of data (e.g. Useful Daylight Autonomy.)
per_face: A Boolean to indicate if the data is per face or per point. In
most cases except for sensor points that are loaded as sensors the data
are provided per face.
colors: A Colors object that defines colors for the legend.
data_range: A list with two values for minimum and maximum values for legend
parameters.
lower_threshold: Lower end of the threshold range for the data.
Data beyond this threshold will be filtered. If not specified, the lower
threshold will be infinite. Defaults to None.
upper_threshold: Upper end of the threshold range for the data.
Data beyond this threshold will be filtered. If not specified, the upper
threshold will be infinite. Defaults to None.
"""
assert len(self.data) == len(data), \
f'Length of input data {len(data)} does not match the length of'\
f' {name} in this dataset {len(self.data)}.'
for count, d in enumerate(data):
self.data[count].add_data(
d, name=name, cell=per_face, colors=colors, data_range=data_range,
lower_threshold=lower_threshold, upper_threshold=upper_threshold)
@ property
def is_empty(self):
return len(self.data) == 0
@ property
def color(self) -> Color:
"""Diffuse color.
By default the dataset will be colored by this color unless color_by property
is set to a dataset value.
"""
return self._color
@ color.setter
def color(self, value: Color):
self._color = value if value else Color(204, 204, 204, 255)
@ property
def color_by(self) -> str:
"""Set the field that the DataSet should colored-by when exported to vtkjs.
By default the dataset will be colored by surface color and not data fields.
"""
return self._color_by
@ color_by.setter
def color_by(self, value: str):
fields_info = self.fields_info
if not value:
self._color_by = None
return
else:
assert value in fields_info, \
f'{value} is not a valid data field for this ModelDataSet. Available ' \
f'data fields are: {list(fields_info.keys())}'
for data in self.data:
data.color_by(value, fields_info[value].per_face)
self._color_by = value
@ property
def opacity(self) -> float:
"""Visualization opacity."""
return self.color.a
@ property
def display_mode(self) -> DisplayMode:
"""Display model (AKA Representation) mode in VTK Glance viewer.
Valid values are:
* Surface / Shaded
* SurfaceWithEdges
* Wireframe
* Points
Default is 0 for Surface mode.
"""
return self._display_mode
@ display_mode.setter
def display_mode(self, mode: DisplayMode):
self._display_mode = mode
@ property
def edge_visibility(self) -> bool:
"""Edge visibility.
The edges will be visible in Wireframe or SurfaceWithEdges modes.
"""
if self.display_mode.value in (0, 2):
return False
else:
return True
[docs] def rgb_to_decimal(self):
"""RGB color in decimal."""
return (self.color[0] / 255, self.color[1] / 255, self.color[2] / 255)
[docs] def to_folder(self, folder, sub_folder=None) -> str:
"""Write data information to a folder.
Args:
folder: Target folder to write the dataset.
sub_folder: Subfolder name for this dataset. By default it will be set to
the name of the dataset.
"""
sub_folder = sub_folder or self.name
target_folder = pathlib.Path(folder, sub_folder)
if len(self.data) == 0:
print(f'ModelDataSet: {self.name} has no data to be exported to folder.')
return
elif len(self.data) == 1:
data = self.data[0]
else:
data = JoinedPolyData.from_polydata(self.data)
return _write_to_folder(data, target_folder.as_posix())
[docs] def as_data_set(self, url=None) -> DataSet:
"""Convert to a vtkjs DataSet object.
Args:
url: Relative path to where PolyData information should be sourced from.
By default url will be set to ModelDataSet name assuming data is dumped
to a folder with the same name.
"""
prop = {
'representation': min(self.display_mode.value, 2),
'edgeVisibility': int(self.edge_visibility),
'diffuseColor': [self.color.r / 255, self.color.g / 255, self.color.b / 255],
'opacity': self.opacity / 255
}
ds_prop = DataSetProperty.parse_obj(prop)
mapper = DataSetMapper()
if self.color_by is not None:
mapper.colorByArrayName = self.color_by
# Getting legend information for each data added to the ModelDataSet object.
legends = []
if self.name == 'Grid' and self.fields_info:
for field_info in self.fields_info.values():
legends.append(field_info.legend_parameter._to_dict())
data = {
'name': self.name,
'httpDataSetReader': {'url': url if url is not None else self.name},
'property': ds_prop.dict(),
'mapper': mapper.dict(),
'legends': legends
}
return DataSet.parse_obj(data)
def __repr__(self) -> str:
return f'ModelDataSet: {self.name}' \
f'\n DataSets: {len(self.data)}\n Color:{self.color}'
def _write_to_file(
polydata: Union[PolyData, JoinedPolyData], target_folder: str, file_name: str,
writer: VTKWriters = VTKWriters.binary
):
"""Write vtkPolyData to a file."""
extension = writer.value
if writer.name == 'legacy':
_writer = vtk.vtkPolyDataWriter()
else:
_writer = vtk.vtkXMLPolyDataWriter()
if writer.name == 'binary':
_writer.SetDataModeToBinary()
else:
_writer.SetDataModeToAscii()
file_path = pathlib.Path(target_folder, f'{file_name}.{extension}')
_writer.SetFileName(file_path.as_posix())
if isinstance(polydata, vtk.vtkPolyData):
_writer.SetInputData(polydata)
else:
_writer.SetInputConnection(polydata.GetOutputPort())
_writer.Write()
return file_path.as_posix()
def _write_to_folder(polydata: Union[PolyData, JoinedPolyData], target_folder: str):
"""Write PolyData to a folder using vtkJSONDataSetWriter."""
writer = vtk.vtkJSONDataSetWriter()
folder = pathlib.Path(target_folder)
folder.mkdir(parents=True, exist_ok=True)
writer.SetFileName(folder.as_posix())
if isinstance(polydata, vtk.vtkPolyData):
writer.SetInputData(polydata)
else:
writer.SetInputConnection(polydata.GetOutputPort())
writer.Write()
return folder.as_posix()