# coding: utf-8
"""Fairyfly Shape."""
from __future__ import division
import math
import re
from ladybug_geometry.geometry2d import Polygon2D
from ladybug_geometry.geometry3d import Point3D, Plane, Face3D
from ._base import _Base
from .search import get_attr_nested
from .properties import ShapeProperties
import fairyfly.writer.shape as writer
[docs]
class Shape(_Base):
"""A single planar shape.
Args:
geometry: A ladybug-geometry Face3D.
identifier: Text string for a unique Shape ID. Must be a UUID in the
format xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx. If None, a UUID will
automatically be generated. (Default: None).
Properties:
* identifier
* display_name
* therm_uuid
* full_id
* parent
* has_parent
* geometry
* vertices
* normal
* area
* perimeter
* min
* max
* center
* tilt
* altitude
* azimuth
* user_data
"""
__slots__ = ('_geometry', '_parent')
def __init__(self, geometry, identifier=None):
"""A single planar shape."""
_Base.__init__(self, identifier) # process the identifier
# process the geometry and basic properties
assert isinstance(geometry, Face3D), \
'Expected ladybug_geometry Face3D. Got {}'.format(type(geometry))
self._geometry = geometry
self._parent = None # _parent will be set when the Shape is added to an object
# initialize properties for extensions
self._properties = ShapeProperties(self)
[docs]
@classmethod
def from_dict(cls, data):
"""Initialize an Shape from a dictionary.
Args:
data: A dictionary representation of an Shape object.
"""
try:
# check the type of dictionary
assert data['type'] == 'Shape', 'Expected Shape dictionary. ' \
'Got {}.'.format(data['type'])
# serialize the dictionary to an object
shape = cls(Face3D.from_dict(data['geometry']), data['identifier'])
if 'display_name' in data and data['display_name'] is not None:
shape.display_name = data['display_name']
if 'user_data' in data and data['user_data'] is not None:
shape.user_data = data['user_data']
# serialize the properties
if data['properties']['type'] == 'ShapeProperties':
shape.properties._load_extension_attr_from_dict(data['properties'])
return shape
except Exception as e:
cls._from_dict_error_message(data, e)
[docs]
@classmethod
def from_vertices(cls, vertices, identifier=None):
"""Create a Shape from vertices with each vertex as an iterable of 3 floats.
Note that this method is not recommended for a shape with one or more holes
since the distinction between hole vertices and boundary vertices cannot
be derived from a single list of vertices.
Args:
vertices: A flattened list of 3 or more vertices as (x, y, z).
identifier: Text string for a unique Shape ID. Must be a UUID in the
format xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx. If None, a UUID will
automatically be generated. (Default: None).
"""
geometry = Face3D(tuple(Point3D(*v) for v in vertices))
return cls(geometry, identifier)
@property
def parent(self):
"""Get the parent object if assigned. None if not assigned.
The parent object is typically a GlazingSystem.
"""
return self._parent
@property
def has_parent(self):
"""Get a boolean noting whether this Shape has a parent object."""
return self._parent is not None
@property
def geometry(self):
"""Get a ladybug_geometry Face3D object representing the Shape."""
return self._geometry
@property
def vertices(self):
"""Get a list of vertices for the shape (in counter-clockwise order)."""
return self._geometry.vertices
@property
def normal(self):
"""Get a ladybug_geometry Vector3D for the direction the shape is pointing.
"""
return self._geometry.normal
@property
def center(self):
"""Get a ladybug_geometry Point3D for the center of the shape.
Note that this is the center of the bounding rectangle around this geometry
and not the area centroid.
"""
return self._geometry.center
@property
def area(self):
"""Get the area of the shape."""
return self._geometry.area
@property
def perimeter(self):
"""Get the perimeter of the shape."""
return self._geometry.perimeter
@property
def min(self):
"""Get a Point3D for the minimum of the bounding box around the object."""
return self._geometry.min
@property
def max(self):
"""Get a Point3D for the maximum of the bounding box around the object."""
return self._geometry.max
@property
def tilt(self):
"""Get the tilt of the geometry between 0 (up) and 180 (down)."""
return math.degrees(self._geometry.tilt)
@property
def altitude(self):
"""Get the altitude of the geometry between +90 (up) and -90 (down)."""
return math.degrees(self._geometry.altitude)
@property
def azimuth(self):
"""Get the azimuth of the geometry, between 0 and 360.
Given Y-axis as North, 0 = North, 90 = East, 180 = South, 270 = West
This will be zero if the Face3D is perfectly horizontal.
"""
return math.degrees(self._geometry.azimuth)
[docs]
def rename_by_attribute(self, format_str='{display_name} - {area}'):
"""Set the display name of this Shape using a format string with attributes.
Args:
format_str: Text string for the pattern with which the Shape will be
renamed. Any property on this class may be used and each
property should be put in curly brackets. Nested properties
can be specified by using "." to denote nesting levels
(eg. properties.energy.construction.display_name). Functions that
return string outputs can also be passed here as long as these
functions defaults specified for all arguments.
"""
matches = re.findall(r'{([^}]*)}', format_str)
attributes = [get_attr_nested(self, m, decimal_count=2) for m in matches]
for attr_name, attr_val in zip(matches, attributes):
format_str = format_str.replace('{{{}}}'.format(attr_name), attr_val)
self.display_name = format_str
return format_str
[docs]
def move(self, moving_vec):
"""Move this Shape along a vector.
Args:
moving_vec: A ladybug_geometry Vector3D with the direction and distance
to move the face.
"""
self._geometry = self.geometry.move(moving_vec)
self.properties.move(moving_vec)
[docs]
def rotate(self, axis, angle, origin):
"""Rotate this Shape by a certain angle around an axis and origin.
Args:
axis: A ladybug_geometry Vector3D axis representing the axis of rotation.
angle: An angle for rotation in degrees.
origin: A ladybug_geometry Point3D for the origin around which the
object will be rotated.
"""
self._geometry = self.geometry.rotate(axis, math.radians(angle), origin)
self.properties.rotate(axis, angle, origin)
[docs]
def rotate_xy(self, angle, origin):
"""Rotate this Shape counterclockwise in the world XY plane by a certain angle.
Args:
angle: An angle in degrees.
origin: A ladybug_geometry Point3D for the origin around which the
object will be rotated.
"""
self._geometry = self.geometry.rotate_xy(math.radians(angle), origin)
self.properties.rotate_xy(angle, origin)
[docs]
def reflect(self, plane):
"""Reflect this Shape across a plane.
Args:
plane: A ladybug_geometry Plane across which the object will be reflected.
"""
self._geometry = self.geometry.reflect(plane.n, plane.o)
self.properties.reflect(plane)
[docs]
def scale(self, factor, origin=None):
"""Scale this Shape 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).
"""
self._geometry = self.geometry.scale(factor, origin)
self.properties.scale(factor, origin)
[docs]
def remove_colinear_vertices(self, tolerance=0.01):
"""Remove all colinear and duplicate vertices from this object's geometry.
Args:
tolerance: The minimum distance between a vertex and the boundary segments
at which point the vertex is considered colinear. Default: 0.01,
suitable for objects in millimeters.
"""
try:
self._geometry = self.geometry.remove_colinear_vertices(tolerance)
except AssertionError as e: # usually a sliver face of some kind
raise ValueError(
'Shape "{}" is invalid with dimensions less than the '
'tolerance.\n{}'.format(self.full_id, e))
[docs]
def insert_vertex(self, point, tolerance=0.01):
"""Insert a Point3D into this Shape's geometry if it lies within the tolerance.
Args:
point: A Point3D to be inserted into this Shape geometry if it lies
within the tolerance of the Shape's existing segments.
tolerance: The minimum distance between a vertex and the boundary segments
at which point the vertex is considered colinear. Default: 0.01,
suitable for objects in millimeters.
"""
# first perform a bounding box check between the point and face
if not self._point_overlaps_bound(point, tolerance):
return None
# evaluate each boundary segment for whether it can be inserted
insert_i = None
for i, seg in enumerate(self.geometry.boundary_segments):
if seg.distance_to_point(point) <= tolerance:
insert_i = i
break
if insert_i is not None:
new_bound = list(self.geometry.boundary)
new_bound.insert(insert_i, point)
self._geometry = Face3D(new_bound, self._geometry.plane, self._geometry.holes)
return None
# evaluate the holes if they exist
if self._geometry.has_holes:
for hi, h_segs in enumerate(self._geometry.hole_segments):
for i, seg in enumerate(h_segs):
if seg.distance_to_point(point) <= tolerance:
new_holes = list(self.geometry.holes)
new_holes[hi].insert(i, point)
self._geometry = Face3D(self._geometry.boundary,
self._geometry.plane, new_holes)
return None
[docs]
def is_geo_equivalent(self, shape, tolerance=0.01):
"""Get a boolean for whether this object is geometrically equivalent to another.
The total number of vertices and the ordering of these vertices can be
different but the geometries must share the same center point and be
next to one another to within the tolerance.
Args:
shape: Another Shape for which geometric equivalency will be tested.
tolerance: The minimum difference between the coordinate values of two
vertices at which they can be considered geometrically equivalent.
Returns:
True if geometrically equivalent. False if not geometrically equivalent.
"""
if self.display_name != shape.display_name:
return False
if abs(self.area - shape.area) > tolerance * self.area:
return False
return self.geometry.is_centered_adjacent(shape.geometry, tolerance)
[docs]
def check_planar(self, tolerance=0.01, raise_exception=True, detailed=False):
"""Check whether all of the Shape's vertices lie within the same plane.
Args:
tolerance: The minimum distance between a given vertex and a the
object's plane at which the vertex is said to lie in the plane.
Default: 0.01, suitable for objects in millimeters.
raise_exception: Boolean to note whether an ValueError should be
raised if a vertex does not lie within the object's plane.
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.
"""
try:
self.geometry.check_planar(tolerance, raise_exception=True)
except ValueError as e:
msg = 'Shape "{}" is not planar.\n{}'.format(self.full_id, e)
full_msg = self._validation_message(
msg, raise_exception, detailed, '200101',
error_type='Non-Planar Geometry')
if detailed: # add the out-of-plane points to helper_geometry
help_pts = [
p.to_dict() for p in self.geometry.non_planar_vertices(tolerance)
]
full_msg[0]['helper_geometry'] = help_pts
return full_msg
return [] if detailed else ''
[docs]
def check_self_intersecting(self, tolerance=0.01, raise_exception=True,
detailed=False):
"""Check whether the edges of the Shape intersect one another (like a bowtie).
Args:
tolerance: The minimum difference between the coordinate values of two
vertices at which they can be considered equivalent. Default: 0.01,
suitable for objects in millimeters.
raise_exception: If True, a ValueError will be raised if the object
intersects with itself. 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.
"""
if self.geometry.is_self_intersecting:
msg = 'Shape "{}" has self-intersecting edges.'.format(self.full_id)
try: # see if it is self-intersecting because of a duplicate vertex
new_geo = self.geometry.remove_duplicate_vertices(tolerance)
if not new_geo.is_self_intersecting:
return [] if detailed else '' # valid with removed dup vertex
except AssertionError:
return [] if detailed else '' # degenerate geometry
full_msg = self._validation_message(
msg, raise_exception, detailed, '200102',
error_type='Self-Intersecting Geometry')
if detailed: # add the self-intersection points to helper_geometry
help_pts = [p.to_dict() for p in self.geometry.self_intersection_points]
full_msg[0]['helper_geometry'] = help_pts
return full_msg
return [] if detailed else ''
[docs]
def to_dict(self, abridged=False, included_prop=None, include_plane=True):
"""Return Shape as a dictionary.
Args:
abridged: Boolean to note whether the extension properties of the
object (ie. THERM materials) should be included in detail (False)
or just referenced by identifier (True). (Default: False).
included_prop: List of properties to filter keys that must be included in
output dictionary. For example ['therm'] will include 'therm' key if
available in properties to_dict. By default all the keys will be
included. To exclude all the keys from extensions use an empty list.
include_plane: Boolean to note wether the plane of the Face3D should be
included in the output. This can preserve the orientation of the
X/Y axes of the plane but is not required and can be removed to
keep the dictionary smaller. (Default: True).
"""
base = {'type': 'Shape'}
base['identifier'] = self.identifier
base['geometry'] = self._geometry.to_dict(include_plane)
base['properties'] = self.properties.to_dict(abridged, included_prop)
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
@property
def to(self):
"""Shape writer object.
Use this method to access Writer class to write the shape in different formats.
Usage:
.. code-block:: python
shape.to.therm(shape) -> therm XML element
"""
return writer
[docs]
@staticmethod
def intersect_adjacency(shapes, tolerance=0.01, plane=None):
"""Intersect the line segments of Shapes to ensure matching adjacencies.
Args:
shapes: A list of Shapes for which adjacent segments will be intersected.
tolerance: The minimum difference between the coordinate values of two
faces at which they can be considered adjacent. (Default: 0.01,
suitable for objects in millimeters).
plane: An optional ladybug-geometry Plane object to set the plane
in which all Shape intersection will be evaluated. If None, the
plane will automatically be senses from the input geometries and
a ValueError will be raised if not all of the input Shapes lie
within the same plane given the input tolerance. (Default: None).
Returns:
An array of Shapes that have been intersected with one another.
"""
# keep track of all data needed to map between 2D and 3D space
if plane is None:
master_plane = shapes[0].geometry.plane
for shape in shapes:
for pt in shape.vertices:
if master_plane.distance_to_point(pt) > tolerance:
msg = 'Not all of the model shapes lie in the same plane as ' \
'each other. Shape "{}" is out of plane by {} units.'.format(
shape.full_id, master_plane.distance_to_point(pt))
raise ValueError(msg)
else:
assert isinstance(plane, Plane), 'Expected Plane for intersect_adjacency. ' \
'Got {}.'.format(type(plane))
master_plane = plane
is_holes = []
polygon_2ds = []
tol = tolerance
# map all Room geometry into the same 2D space
for shape in shapes:
is_holes.append(False) # record that first Polygon doesn't have holes
pts_2d = tuple(master_plane.xyz_to_xy(pt) for pt in shape.geometry.boundary)
polygon_2ds.append(Polygon2D(pts_2d))
# of there are holes in the face, add them as their own polygons
if shape.geometry.has_holes:
for hole in shape.geometry.holes:
is_holes.append(True)
pts_2d = tuple(master_plane.xyz_to_xy(pt) for pt in hole)
polygon_2ds.append(Polygon2D(pts_2d))
# intersect the Room2D polygons within the 2D space
int_poly = Polygon2D.intersect_polygon_segments(polygon_2ds, tol)
# convert the resulting coordinates back to 3D space
face_pts = []
for poly, is_hole in zip(int_poly, is_holes):
pt_3d = [master_plane.xy_to_xyz(pt) for pt in poly]
if not is_hole:
face_pts.append((pt_3d, []))
else:
face_pts[-1][1].append(pt_3d)
# rebuild all of the geometries to the input Shapes
for i, face_loops in enumerate(face_pts):
if len(face_loops[1]) == 0: # no holes
new_geo = Face3D(face_loops[0], shapes[i].geometry.plane)
else: # ensure holes are included
new_geo = Face3D(face_loops[0], shapes[i].geometry.plane, face_loops[1])
shapes[i]._geometry = new_geo
return shapes
def _point_overlaps_bound(self, point, distance):
"""Check if a point lies within the bounding box around this shape."""
# Bounding box check using the Separating Axis Theorem
geo1_width = self.max.x - self.min.x
dist_btwn_x = abs(self.center.x - point.x)
x_gap_btwn_box = dist_btwn_x - (0.5 * geo1_width)
if x_gap_btwn_box > distance:
return False # overlap impossible
geo1_depth = self.max.y - self.min.y
dist_btwn_y = abs(self.center.y - point.y)
y_gap_btwn_box = dist_btwn_y - (0.5 * geo1_depth)
if y_gap_btwn_box > distance:
return False # overlap impossible
geo1_height = self.max.z - self.min.z
dist_btwn_z = abs(self.center.z - point.z)
z_gap_btwn_box = dist_btwn_z - (0.5 * geo1_height)
if z_gap_btwn_box > distance:
return False # overlap impossible
return True # overlap exists
def __copy__(self):
new_shape = Shape(self.geometry, self.identifier)
new_shape._display_name = self._display_name
new_shape._user_data = None if self.user_data is None else self.user_data.copy()
new_shape._properties._duplicate_extension_attr(self._properties)
return new_shape
def __len__(self):
return len(self._geometry)
def __repr__(self):
return 'Shape: %s' % self.display_name