Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(visualization): Add support for SVG axon views #44

Merged
merged 1 commit into from
Mar 4, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 26 additions & 3 deletions ladybug_display/analysis.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# coding=utf-8
"""Class for representing geometry colored with data according to legend parameters."""
from __future__ import division
import math
import uuid

from ladybug_geometry.geometry2d import Vector2D, Point2D, Ray2D, LineSegment2D, \
Expand All @@ -9,6 +10,7 @@
Polyline3D, Arc3D, Face3D, Mesh3D, Polyface3D, Sphere, Cone, Cylinder
from ladybug_geometry.bounding import bounding_box
from ladybug_geometry.dictutil import geometry_dict_to_object
from ladybug_geometry.projection import project_geometry_2d

from ladybug.legend import Legend, LegendParameters, LegendParametersCategorized
from ladybug.graphic import GraphicContainer
Expand Down Expand Up @@ -369,6 +371,7 @@ def rotate_xy(self, angle, origin):
object will be rotated.
"""
new_geo = []
angle = math.radians(angle)
origin_2d = Point2D(origin.x, origin.y)
for geo in self._geometry:
try:
Expand Down Expand Up @@ -427,14 +430,34 @@ def scale(self, factor, origin=None):
l_par.properties_3d._text_height = \
l_par.properties_3d._text_height * factor
l_par.properties_3d._base_plane = \
l_par.properties_3d._base_plane.scale(factor)
v_data._legend._legend_par = l_par
l_par.properties_3d._base_plane.scale(factor, origin)
self._geometry = tuple(new_geo)
self._min_point = None
self._max_point = None
self._min_point_with_legend = None
self._max_point_with_legend = None

def project_2d(self, plane):
""""Project this AnalysisGeometry into a plane to get it in the plane's 2D system.

This is useful as a pre-step before converting to SVG to get the geometry
from a certain view.

Args:
plane: The Plane into which the AnalysisGeometry will be projected.
"""
self._geometry = tuple(project_geometry_2d(plane, self.geometry))
for v_data in self.data_sets:
l_par = v_data.legend.legend_parameters
l_plane = l_par.properties_3d._base_plane
origin = plane.xyz_to_xy(plane.project_point(l_plane.o))
l_par.properties_3d._base_plane = \
Plane(n=l_plane.n, o=Point3D(origin.x, origin.y), x=l_plane.x)
self._min_point = None
self._max_point = None
self._min_point_with_legend = None
self._max_point_with_legend = None

def to_dict(self):
"""Get AnalysisGeometry as a dictionary."""
base = {
Expand Down Expand Up @@ -1154,7 +1177,7 @@ def duplicate(self):

def __copy__(self):
new_obj = VisualizationData(
self.values, self._legend_parameters, self._data_type, self._unit)
self.values, self.legend_parameters, self._data_type, self._unit)
new_obj._user_data = None if self.user_data is None else self.user_data.copy()
return new_obj

Expand Down
23 changes: 23 additions & 0 deletions ladybug_display/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

from ladybug_geometry.geometry2d import Vector2D, Point2D
from ladybug_geometry.bounding import bounding_box
from ladybug_geometry.projection import project_geometry_2d

from .geometry2d._base import _DisplayBase2D
from .geometry3d._base import _DisplayBase3D
Expand Down Expand Up @@ -173,6 +174,28 @@ def scale(self, factor, origin=None):
elif isinstance(geo, _DisplayBase2D):
geo.scale(factor, origin_2d)

def project_2d(self, plane):
""""Project this AnalysisGeometry into a plane to get it in the plane's 2D system.

This is useful as a pre-step before converting to SVG to get the geometry
from a certain view.

Args:
plane: The Plane into which the AnalysisGeometry will be projected.
"""
proj_geos = []
for geo in self._geometry:
p_geo = project_geometry_2d(plane, [geo.geometry])[0]
if not isinstance(p_geo, geo.geometry.__class__):
p_geo_class = DISPLAY_MAP[p_geo.__class__][0]
new_geo = p_geo_class(p_geo)
new_geo._transfer_attributes(geo)
else:
geo._geometry = p_geo
new_geo = geo
proj_geos.append(new_geo)
self._geometry = tuple(proj_geos)

def to_dict(self):
"""Get ContextGeometry as a dictionary."""
base = {
Expand Down
19 changes: 19 additions & 0 deletions ladybug_display/geometry2d/_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,10 @@ def scale(self, factor, origin=None):
"""
self._geometry = self.geometry.scale(factor, origin)

def _transfer_attributes(self, other_obj):
"""Transfer attributes from another object to this one."""
pass

def __repr__(self):
return 'Ladybug Display 2D Base Object'

Expand Down Expand Up @@ -103,6 +107,10 @@ def color(self, value):
'object color. Got {}.'.format(type(value))
self._color = value

def _transfer_attributes(self, other_obj):
"""Transfer attributes from another object to this one."""
self._color = other_obj._color


class _SingleColorModeBase2D(_SingleColorBase2D):
"""A base class for ladybug-display geometry objects with a display mode.
Expand Down Expand Up @@ -145,6 +153,11 @@ def display_mode(self, value):
'following:\n{}'.format(value, DISPLAY_MODES))
self._display_mode = value

def _transfer_attributes(self, other_obj):
"""Transfer attributes from another object to this one."""
self._color = other_obj._color
self._display_mode = other_obj._display_mode


class _LineCurveBase2D(_SingleColorBase2D):
"""A base class for all line-like 2D geometry objects.
Expand Down Expand Up @@ -210,3 +223,9 @@ def line_type(self, value):
'line_type {} is not recognized.\nChoose from the '
'following:\n{}'.format(value, LINE_TYPES))
self._line_type = value

def _transfer_attributes(self, other_obj):
"""Transfer attributes from another object to this one."""
self._color = other_obj._color
self._line_width = other_obj._line_width
self._line_type = other_obj._line_type
19 changes: 19 additions & 0 deletions ladybug_display/geometry3d/_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,10 @@ def scale(self, factor, origin=None):
"""
self._geometry = self.geometry.scale(factor, origin)

def _transfer_attributes(self, other_obj):
"""Transfer attributes from another object to this one."""
pass

def __repr__(self):
return 'Ladybug Display 3D Base Object'

Expand Down Expand Up @@ -113,6 +117,10 @@ def color(self, value):
'object color. Got {}.'.format(type(value))
self._color = value

def _transfer_attributes(self, other_obj):
"""Transfer attributes from another object to this one."""
self._color = other_obj._color


class _SingleColorModeBase3D(_SingleColorBase3D):
"""A base class for ladybug-display geometry objects with a display mode.
Expand Down Expand Up @@ -155,6 +163,11 @@ def display_mode(self, value):
'following:\n{}'.format(value, DISPLAY_MODES))
self._display_mode = value

def _transfer_attributes(self, other_obj):
"""Transfer attributes from another object to this one."""
self._color = other_obj._color
self._display_mode = other_obj._display_mode


class _LineCurveBase3D(_SingleColorBase3D):
"""A base class for all line-like 3D geometry objects.
Expand Down Expand Up @@ -220,3 +233,9 @@ def line_type(self, value):
'line_type {} is not recognized.\nChoose from the '
'following:\n{}'.format(value, LINE_TYPES))
self._line_type = value

def _transfer_attributes(self, other_obj):
"""Transfer attributes from another object to this one."""
self._color = other_obj._color
self._line_width = other_obj._line_width
self._line_type = other_obj._line_type
86 changes: 61 additions & 25 deletions ladybug_display/visualization.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
# coding=utf-8
from __future__ import division
import os
import sys
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
from ladybug_geometry.geometry3d import Vector3D, Point3D, Plane
from ladybug_geometry.bounding import bounding_box

from ._base import _VisualizationBase
Expand Down Expand Up @@ -57,6 +57,17 @@ class VisualizationSet(_VisualizationBase):
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."""
Expand Down Expand Up @@ -111,10 +122,11 @@ def from_file(cls, vis_set_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 open(vis_set_file) as inf:
with io.open(vis_set_file, encoding='utf-8') as inf:
try:
first_char = inf.read(1)
is_json = True if first_char == '{' else False
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
Expand All @@ -130,12 +142,8 @@ def from_json(cls, json_file):
json_file: Path to VisualizationSet JSON file.
"""
assert os.path.isfile(json_file), 'Failed to find %s' % json_file
if (sys.version_info < (3, 0)):
with open(json_file) as inf:
data = json.load(inf)
else:
with open(json_file, encoding='utf-8') as inf:
data = json.load(inf)
with io.open(json_file, encoding='utf-8') as inf:
data = json.load(inf)
return cls.from_dict(data)

@classmethod
Expand Down Expand Up @@ -485,7 +493,7 @@ def to_pkl(self, name, folder):
return vs_file

def to_svg(self, width=800, height=600, margin=None,
render_3d_legend=False, render_2d_legend=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.
Expand All @@ -506,13 +514,43 @@ def to_svg(self, width=800, height=600, margin=None,
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
Expand All @@ -530,20 +568,18 @@ def to_svg(self, width=800, height=600, margin=None,
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(self.geometry):
if not geo.hidden:
geo = geo.duplicate() # duplicate to avoid mutating the input
geo.move(move_vec)
geo.scale(scale_fac)
geo.move(center_vec)
if isinstance(geo, AnalysisGeometry):
svg_data = geo.to_svg(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)
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(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
Expand Down
2 changes: 1 addition & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
@@ -1 +1 @@
ladybug-core>=0.42.29
ladybug-core>=0.44.6
31 changes: 31 additions & 0 deletions tests/visualization_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -217,3 +217,34 @@ def test_psych_chart_to_svg():
svg_file = svg_data.to_file(name='PsychChart', folder='./tests/svg')
assert os.path.isfile(svg_file)
os.remove(svg_file)


def test_sunpath_axon_to_svg():
"""Test the translation of an Sunpath VisualizationSet to SVG with an Axon view."""
path = './tests/epw/chicago.epw'
epw = EPW(path)
sunpath = Sunpath.from_location(epw.location)
hoys = [DateTime(3, 2, i).hoy for i in range(24)]
vis_set = sunpath_to_vis_set(sunpath, hoys=hoys)

svg_data = vis_set.to_svg(1200, 1000, view='SE')
svg_file = svg_data.to_file(name='Sunpath_Axon', folder='./tests/svg')
assert os.path.isfile(svg_file)
os.remove(svg_file)


def test_daylight_study_to_svg():
"""Test the translation of an a daylight VisualizationSet to SVG with an Axon view."""
path = './tests/vsf/classroom.vsf'
vis_set = VisualizationSet.from_file(path)
vis_set.geometry = (vis_set[-1],) + vis_set[:-1]
data_i = vis_set.geometry[-1].active_data
vis_set.geometry[-1][data_i].legend_parameters.vertical = False
vis_set.geometry[-1][data_i].legend_parameters.decimal_count = 0
vis_set.geometry[-1][data_i].legend_parameters.title = \
'Useful Daylight Illuminance (%)'

svg_data = vis_set.to_svg(1200, 1000, view='SE', render_2d_legend=True)
svg_file = svg_data.to_file(name='Daylight_Study', folder='./tests/svg')
assert os.path.isfile(svg_file)
os.remove(svg_file)
1 change: 1 addition & 0 deletions tests/vsf/classroom.vsf

Large diffs are not rendered by default.