diff --git a/app.py b/app.py index 77fcbc5..7893e1d 100644 --- a/app.py +++ b/app.py @@ -1,51 +1,48 @@ import datetime import json -from io import BytesIO +import os +import tempfile +from pathlib import Path +import compute_rhino3d.Grasshopper as gh +import compute_rhino3d.Util +import rhino3dm from viktor import ViktorController, File -from viktor.external.generic import GenericAnalysis -from viktor.parametrization import NumberField -from viktor.parametrization import Text -from viktor.parametrization import ViktorParametrization, DateField +from viktor.parametrization import ViktorParametrization, DateField, NumberField, ToggleButton, Text, Lookup +from viktor.utils import memoize from viktor.views import GeometryAndDataView, GeometryAndDataResult, DataGroup, DataItem class Parametrization(ViktorParametrization): intro = Text( - "## Grasshopper Analysis app \n This app parametrically generates and analyses a " + "# Sunlight Hours Analysis with Grasshopper & Ladybug 🐞 🦗 🦏 \n This app parametrically generates and analyses a " "3D model of a tower using a Grasshopper script. " - "The sun hour analysis is carried out using the Ladybug plugin for Grasshopper. " - "Geometry and resulting values are sent back and forth to the Grasshopper script in real-time." - "\n\n Please fill in the following parameters:" + "The sun hours analysis is carried out using the Ladybug plugin for Grasshopper. \n " + "\n Please fill in the following parameters:" ) # Input fields - floorplan_width = NumberField( - "Floorplan width", default=15, min=10, max=18, suffix="m", flex=100, variant='slider', step=1 - ) - twist_top = NumberField( - "Twist top", default=0.65, min=0.20, max=1.00, variant='slider', flex=100, step=0.01 - ) - floor_height = NumberField( - "Floor height", default=3.5, min=2.5, max=5.0, suffix="m", variant='slider', flex=100, step=0.1 - ) - tower_height = NumberField( - "Tower height", default=75, min=20, max=100, suffix="m", flex=100, variant='slider', step=1 - ) - rotation = NumberField( - "Rotation", default=60, min=0, max=90, suffix="°", flex=100, variant='slider', step=1 - ) - date = DateField( - 'Date for the sun hour analysis', default=datetime.date.today(), flex=100 - ) + floorplan_width = NumberField("Floorplan width", default=15, min=10, max=18, suffix="m", flex=100, variant='slider', step=1) + twist_top = NumberField("Twist top", default=0.65, min=0.20, max=1.00, variant='slider', flex=100, step=0.01) + floor_height = NumberField("Floor height", default=3.5, min=2.5, max=5.0, suffix="m", variant='slider', flex=100, step=0.1) + tower_height = NumberField("Tower height", default=75, min=20, max=100, suffix="m", flex=100, variant='slider', step=1) + rotation = NumberField("Rotation", default=60, min=0, max=90, suffix="°", flex=100, variant='slider', step=1) + run_solar_analysis = ToggleButton("Sun Hours Analysis") + date = DateField("Date for the sun hour analysis", default=datetime.date.today(), flex=100, visible=Lookup('run_solar_analysis')) + + outro = Text(" ## Start building Grasshopper cloud apps [here](https://community.viktor.ai/t/sunlight-hours-analysis-with-grasshopper-and-ladybug/1250?u=mostafa) 🚀 ") + ps = Text("PS: If the app starts after an hour of inactivity, it takes an extra 30 seconds to get the Rhino Compute server up and running.") class Controller(ViktorController): label = 'My Entity Type' parametrization = Parametrization(width=30) - @GeometryAndDataView("Geometry", duration_guess=0, update_label='Run Grasshopper') + @GeometryAndDataView("Output", duration_guess=0, update_label='Run Grasshopper') def run_grasshopper(self, params, **kwargs): + # Credentials for Rhino Compute api + compute_rhino3d.Util.url = os.getenv("RHINO_COMPUTE_URL") + compute_rhino3d.Util.apiKey = os.getenv("RHINO_COMPUTE_API_KEY") # Replace datetime object with month and day date: datetime.date = params["date"] @@ -53,25 +50,93 @@ def run_grasshopper(self, params, **kwargs): params["day"] = date.day params.pop("date") - # Create a JSON file from the input parameters - input_json = json.dumps(params) + # Run Grasshopper file with input parameters + output = self.evaluate_grasshopper(str(Path(__file__).parent / 'files' / 'script.gh'), params) + + # Create a new rhino3dm file and save resulting geometry to file + file = rhino3dm.File3dm() + output_geometry = self.get_value_from_tree(output, "Geometry", index=0) + output_geometry2 = self.get_value_from_tree(output, "Geometry2") + + obj = rhino3dm.CommonObject.Decode(json.loads(output_geometry)) + file.Objects.AddMesh(obj) + + for data in output_geometry2: + obj2 = rhino3dm.CommonObject.Decode(json.loads(data)) + file.Objects.Add(obj2) + + # Add solar analysis legend values + point_x = self.get_value_from_tree(output, "pointsx") + point_y = self.get_value_from_tree(output, "pointsy") + solar_vals = self.get_value_from_tree(output, "solar_values") + for i in range(len(point_y)): + pointyvalue = float(point_y[i].replace('"', "")) + pointxvalue = float(point_x[i].replace('"', "")) + solarv = str(solar_vals[i]) + file.Objects.AddTextDot(text=solarv, location=rhino3dm._rhino3dm.Point3d(pointxvalue, pointyvalue, 0)) + + # Add compass orientation + compass__x = self.get_value_from_tree(output, "compass_x") + compass__y = self.get_value_from_tree(output, "compass_y") + comapass__text = self.get_value_from_tree(output, "compass_text") + for i in range(len(compass__x)): + compass_xpoint = float(compass__x[i].replace('"', "")) + compass_ypoint = float(compass__y[i].replace('"', "")) + compassv= str(comapass__text[i]) + file.Objects.AddTextDot(text=compassv, location=rhino3dm._rhino3dm.Point3d(compass_xpoint,compass_ypoint, 0)) + + # Save Rhino file to a temporary file + temp_file = tempfile.NamedTemporaryFile(suffix=".3dm", delete=False, mode="wb") + temp_file.close() + file.Write(temp_file.name, 7) + rhino_3dm_file = File.from_path(Path(temp_file.name)) + + # Parse output data + output_params = ["floor_area", "gross_area", "facade_area"] + if params.run_solar_analysis: + output_params.extend(["avg_sun_hours_context", "avg_sun_hours_tower"]) + + output_values = {} + for key in output_params: + val = self.get_value_from_tree(output, key, index=0) + output_values[key] = float(val.replace("\"", "")) + + data_items = [ + DataItem('Floor area', output_values["floor_area"], suffix='m²', number_of_decimals=0), + DataItem('Gross area', output_values["gross_area"], suffix='m²', number_of_decimals=0), + DataItem('Facade area', output_values["facade_area"], suffix='m²', number_of_decimals=0), + ] + + if params.run_solar_analysis: + data_items.extend([ + DataItem('Avg sun hours context', output_values["avg_sun_hours_context"], suffix='h', number_of_decimals=2), + DataItem('Avg sun hours tower', output_values["avg_sun_hours_tower"], suffix='h', number_of_decimals=2), + ]) - # Generate the input files - files = [('input.json', BytesIO(bytes(input_json, 'utf8')))] + return GeometryAndDataResult(geometry=rhino_3dm_file, geometry_type="3dm", data=DataGroup(*data_items)) - # Run the Grasshopper analysis and obtain the output files - generic_analysis = GenericAnalysis(files=files, executable_key="run_grasshopper", output_filenames=[ - "geometry.3dm", "output.json" - ]) - generic_analysis.execute(timeout=60) - rhino_3dm_file = generic_analysis.get_output_file("geometry.3dm", as_file=True) - output_values: File = generic_analysis.get_output_file("output.json", as_file=True) + @staticmethod + @memoize + def evaluate_grasshopper(file_path, params): + # Create the input DataTree + input_trees = [] + for key, value in params.items(): + tree = gh.DataTree(key) + tree.Append([{0}], [str(value).lower()]) + input_trees.append(tree) - # Create a DataGroup object to display output data - output_dict = json.loads(output_values.getvalue()) - print(output_dict) - data_group = DataGroup( - *[DataItem(key.replace("_", " "), val) for key, val in output_dict.items()] - ) + return gh.EvaluateDefinition(file_path, input_trees) - return GeometryAndDataResult(geometry=rhino_3dm_file, geometry_type="3dm", data=data_group) + @staticmethod + def get_value_from_tree(datatree: dict, param_name: str, index=None): + """Get first value in datatree that matches given param_name""" + for val in datatree['values']: + if val["ParamName"] == param_name: + try: + if index is not None: + return val['InnerTree']['{0}'][index]['data'] + return [v['data'] for v in val['InnerTree']['{0}']] + except: + if index is not None: + return val['InnerTree']['{0}'][index]['data'] + return [v['data'] for v in val['InnerTree']['{0;0}']] diff --git a/files/input.json b/files/input.json deleted file mode 100644 index 52a5913..0000000 --- a/files/input.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "floorplan_width": 15, - "twist_top": 0.65, - "floor_height": 3.5, - "tower_height": 75, - "rotation": 60, - "month": 3, - "day": 21 -} \ No newline at end of file diff --git a/files/run_grasshopper.py b/files/run_grasshopper.py deleted file mode 100644 index f082c0b..0000000 --- a/files/run_grasshopper.py +++ /dev/null @@ -1,57 +0,0 @@ -# Pip install required packages -import os -import json -import compute_rhino3d.Grasshopper as gh -import compute_rhino3d.Util -import rhino3dm - -# Set the compute_rhino3d.Util.url, default URL is http://localhost:6500/ -compute_rhino3d.Util.url = 'http://localhost:6500/' - -# Define path to local working directory -workdir = os.getcwd() + '\\' - -# Read input parameters from JSON file -with open(workdir + 'input.json') as f: - input_params = json.load(f) - -# Create the input DataTree -input_trees = [] -for key, value in input_params.items(): - tree = gh.DataTree(key) - tree.Append([{0}], [str(value)]) - input_trees.append(tree) - -# Evaluate the Grasshopper definition -output = gh.EvaluateDefinition( - workdir + 'script.gh', - input_trees -) - - -def get_value_from_tree(datatree: dict, param_name: str): - """Get first value in datatree that matches given param_name""" - for val in datatree['values']: - if val["ParamName"] == param_name: - return val['InnerTree']['{0}'][0]['data'] - - -# Create a new rhino3dm file and save resulting geometry to file -file = rhino3dm.File3dm() -output_geometry = get_value_from_tree(output, "Geometry") -obj = rhino3dm.CommonObject.Decode(json.loads(output_geometry)) -file.Objects.AddMesh(obj) - -# Save Rhino file to working directory -file.Write(workdir + 'geometry.3dm', 7) - -# Parse output data -output_values = {} -for key in ["floor_area", "gross_area", "facade_area", "avg_sun_hours_context", "avg_sun_hours_tower"]: - val = get_value_from_tree(output, key) - val = val.replace("\"", "") - output_values[key] = val - -# Save json file with output data to working directory -with open(workdir + 'output.json', 'w') as f: - json.dump(output_values, f) diff --git a/files/script.gh b/files/script.gh index 3ff1b4b..75286cc 100644 Binary files a/files/script.gh and b/files/script.gh differ diff --git a/requirements.txt b/requirements.txt index 80437f7..08acfb1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,3 @@ -viktor==14.4.0 \ No newline at end of file +viktor==14.4.0 +compute-rhino3d==0.12.2 +rhino3dm==7.15.0 \ No newline at end of file diff --git a/viktor.config.toml b/viktor.config.toml index b824f52..fbc6f54 100644 --- a/viktor.config.toml +++ b/viktor.config.toml @@ -1,2 +1,2 @@ app_type = 'editor' -python_version = '3.11' # '3.8' | '3.9' | '3.10' | '3.11' +python_version = '3.10' # '3.8' | '3.9' | '3.10' | '3.11'