diff --git a/src/fourc_webviewer/fourc_webserver.py b/src/fourc_webviewer/fourc_webserver.py index 91542db..5c0f652 100644 --- a/src/fourc_webviewer/fourc_webserver.py +++ b/src/fourc_webviewer/fourc_webserver.py @@ -4,6 +4,7 @@ import copy import re +import shutil import tempfile from pathlib import Path @@ -12,6 +13,7 @@ import yaml from fourcipp import CONFIG from fourcipp.fourc_input import FourCInput, ValidationError +from fourcipp.utils.yaml_io import load_yaml from trame.app import get_server from trame.decorators import TrameApp, change, controller @@ -68,6 +70,10 @@ def __init__( self.server = get_server() + # initialize include upload value: False (bottom sheet with include upload is not displayed until there is a fourcyaml file uploaded) + self.state.include_upload_open = False + self.state.included_files = [] + # declare server-side variable dict: variables which should not # be exposed to the client-side self._server_vars = {} @@ -120,18 +126,11 @@ def __init__( # initialize state object self.init_state_and_server_vars() - # convert file to vtu and create dedicated render objects - fourc_geometry = FourCGeometry( - fourc_yaml_file=fourc_yaml_file, - temp_dir=Path(self._server_vars["temp_dir_object"].name), - ) - self.state.vtu_path = fourc_geometry.vtu_file_path + if "render_window" not in self._server_vars: + self._server_vars["render_window"] = pv.Plotter() + self.state.vtu_path = "" - if self.state.vtu_path == "": - self.state.read_in_status = self.state.all_read_in_statuses[ - "vtu_conversion_error" - ] - self.init_pyvista_render_objects() + self._server_vars["fourc_yaml_file_dir"] = Path(fourc_yaml_file).parent # create ui create_gui(self.server, self._server_vars["render_window"]) @@ -267,13 +266,58 @@ def init_pyvista_render_objects(self): The saved vtu file path is hereby utilized. """ + # convert file to vtu and create dedicated render objects + if not ( + Path(self._server_vars["temp_dir_object"].name) + / self._server_vars["fourc_yaml_name"] + ).exists(): + raise Exception( + "File does not exist: " + + self._server_vars["temp_dir_object"].name + + "/" + + self._server_vars["fourc_yaml_name"] + ) + + # contains the dict of the structure geometry section of the current yaml file. + structure_geometry_section = self._server_vars[ + "fourc_yaml_content" + ].sections.get("STRUCTURE GEOMETRY", {}) + # contains the name of the geometry file defined in the STRUCTURE GEOMETRY section. + geometry_file_name = structure_geometry_section.get("FILE", None) + + if geometry_file_name: + # ensure that geometry_file_name really only contains the name and not a path + geometry_file_name = Path(geometry_file_name).name + if not ( + Path(self._server_vars["temp_dir_object"].name) / geometry_file_name + ).exists(): + # if the current yaml file references a geometry file it will have already been loaded into the temp dir by now. + # if not something went wrong + raise Exception( + "File does not exist: " + + self._server_vars["temp_dir_object"].name + + "/" + + geometry_file_name + ) + + # creates the FourCGeometry. By now every used file has to be in the temp dir + fourc_geometry = FourCGeometry( + fourc_yaml_file=Path(self._server_vars["temp_dir_object"].name) + / self._server_vars["fourc_yaml_name"], + temp_dir=Path(self._server_vars["temp_dir_object"].name), + ) + self.state.vtu_path = fourc_geometry.vtu_file_path # render window initialization: to be done only once while starting the webviewer, otherwise no proper binding within the current setup! if "render_window" not in self._server_vars: self._server_vars["render_window"] = pv.Plotter() - self._server_vars["render_window"].clear_actors() + if self.state.vtu_path == "": + self.state.read_in_status = self.state.all_read_in_statuses[ + "vtu_conversion_error" + ] + self._server_vars["render_window"].clear_actors() problem_mesh = pv.read(self.state.vtu_path) # get problem mesh self._actors["problem_mesh"] = self._server_vars["render_window"].add_mesh( @@ -336,6 +380,12 @@ def init_pyvista_render_objects(self): all_result_descriptions = self.state.result_description_section.keys() for dc in all_result_descriptions: + if ( + not self.state.result_description_section[dc] + .get("PARAMETERS", {}) + .get("NODE") + ): + continue node_coords = problem_mesh.points[ self.state.result_description_section[dc]["PARAMETERS"]["NODE"] - 1, :, @@ -353,6 +403,8 @@ def init_pyvista_render_objects(self): ) self.update_pyvista_render_objects() + self._server_vars["render_window"].reset_camera() + def update_pyvista_render_objects(self): """Update/ initialize pyvista view objects (reader, thresholds, global COS, ...) for the rendered window. @@ -361,7 +413,7 @@ def update_pyvista_render_objects(self): """ legend_items = [] - for dc in self._actors["result_description_nodes"].values(): + for dc in self._actors.get("result_description_nodes", {}).values(): dc.SetVisibility(False) if ( self.state.selected_main_section_name == "RESULT DESCRIPTION" @@ -374,7 +426,7 @@ def update_pyvista_render_objects(self): ].SetVisibility(True) legend_items.append(("Selected result description", "deepskyblue")) - for rd in self._actors["dc_geometry_entities"].values(): + for rd in self._actors.get("dc_geometry_entities", {}).values(): rd.SetVisibility(False) if ( self.state.selected_main_section_name == "DESIGN CONDITIONS" @@ -386,7 +438,7 @@ def update_pyvista_render_objects(self): ].SetVisibility(True) legend_items.append(("Selected design condition", "navy")) - for mat in self._actors["material_meshes"].values(): + for mat in self._actors.get("material_meshes", {}).values(): mat.SetVisibility(False) if ( self.state.selected_material @@ -746,7 +798,9 @@ def init_result_description_state_and_server_vars(self): # get result description section result_description_section = copy.deepcopy( - self._server_vars["fourc_yaml_content"]["RESULT DESCRIPTION"] + self._server_vars["fourc_yaml_content"].sections.get( + "RESULT DESCRIPTION", {} + ) ) # initialize empty dict as the result description section @@ -957,6 +1011,77 @@ def init_funct_state_and_server_vars(self): 6 # precision for the user input of the values defined above: x, y, z and t_max ) + def append_include_files(self, file_paths): + """Appends list of files to the included files input field. + + They will be uploaded before the user can edit or view the file. + """ + # get the file names of the needed files. These names will be shown in the pop up window. + yaml_include_names = [Path(file_path).name for file_path in file_paths] + included_files = copy.deepcopy(self.state.included_files) + # make a copy, so the state triggers reactivity + for include_name in yaml_include_names: + # this file path is created to check weather the needed file is already present on the server. + # If the user is, for example, continuously working on a file that references an exodus file + # they can copy it into the server file directory and it will be opened automatically + # without prompting the user to upload the .exo file every time. + include_file_server = Path( + self._server_vars["fourc_yaml_file_dir"], + include_name, + ) + # every file the user is working on will be loaded into the temp directory. + # This is because the FourCGeometry Constructor requires the .yaml file and the .exo file to be in the same directory. + include_temp_path = Path( + self._server_vars["temp_dir_object"].name, + include_name, + ) + + # if the file has been copied into the server directory it will be loaded into the temp dir automatically + # without prompting the user every time they open the .yaml file. + if include_file_server.is_file(): + shutil.copyfile(include_file_server, include_temp_path) + elif not include_temp_path.is_file(): + # This is the standard case. The file is not present on the server and the user is prompted to upload it. + included_files.append( + { + "name": include_name, + "uploaded": False, + "error": None, + "content": None, + } + ) + # trigger reactivity + self.state.included_files = included_files + + def request_included_files(self): + """Requests the included files from the user by opening a the include + files dialog and setting up the state variable accordingly.""" + + self.state.included_files = [] + # if the uploaded .yaml file contains a reference to a geometry file, this variable will be it's name. + # otherwise it will be None + geometry_file_name = ( + self._server_vars.get("fourc_yaml_content") + .sections.get("STRUCTURE GEOMETRY", {}) + .get("FILE", None) + ) + + if geometry_file_name: + self.append_include_files([geometry_file_name]) + # add yaml includes + yaml_include_names = [ + Path(file_path).name + for file_path in self._server_vars.get("fourc_yaml_content").sections.get( + "INCLUDES", [] + ) + ] + self.append_include_files(yaml_include_names) + + if self.state.included_files: + self.state.include_upload_open = True + else: + self.confirm_included_files() + def sync_funct_section_from_state(self): """Syncs the server-side functions section based on the current values of the dedicated state variables.""" @@ -1028,12 +1153,22 @@ def init_mode_state_vars(self): def change_fourc_yaml_file(self, fourc_yaml_file, **kwargs): """Reaction to change of state.fourc_yaml_file.""" + if not fourc_yaml_file or fourc_yaml_file["name"].split(".")[-1] not in [ + "yaml", + "yml", + "DAT", + "dat", + ]: + print( + "Warning: File does not have a .yml / .yaml / .dat / .DAT ending or is empty. Try opening another file." + ) + return # create temporary fourc yaml file from the content of the given file temp_fourc_yaml_file = Path( self._server_vars["temp_dir_object"].name, fourc_yaml_file["name"] ) - with open(temp_fourc_yaml_file, "w") as f: - f.writelines(fourc_yaml_file["content"].decode("utf-8")) + with open(temp_fourc_yaml_file, "wb") as f: + f.write(fourc_yaml_file["content"]) # read content, lines and other details of the given file ( @@ -1045,12 +1180,72 @@ def change_fourc_yaml_file(self, fourc_yaml_file, **kwargs): ) = read_fourc_yaml_file(temp_fourc_yaml_file) self._server_vars["fourc_yaml_name"] = Path(temp_fourc_yaml_file).name - # set vtu file path empty to make the convert button visible - # (only if the function was not run yet, i.e., after the - # initial rendering) - self._server_vars["render_count"]["change_fourc_yaml_file"] += 1 - if self._server_vars["render_count"]["change_fourc_yaml_file"] > 1: - self.state.vtu_path = "" + if self._server_vars["fourc_yaml_read_in_status"]: + self.state.read_in_status = self.state.all_read_in_statuses["success"] + else: + self.state.read_in_status = self.state.all_read_in_statuses[ + "validation_error" + ] + + self._server_vars["fourc_yaml_name"] = temp_fourc_yaml_file.name + + self.request_included_files() + + @controller.set("on_upload_include_file") + def on_upload_include_file(self, uploaded_file, index, **kwargs): + """Gets called when an included file is uploaded. + + Saves the uploaded file into the state variable. + """ + self.state.included_files[index]["content"] = uploaded_file + + if uploaded_file["name"].split(".")[-1] in ["yaml", "yml"]: + content = ( + load_yaml(uploaded_file.get("content", {}).get("content", "")) or {} + ) + yaml_include_names = [ + Path(file_path).name for file_path in content.get("INCLUDES", []) + ] + self.append_include_files(yaml_include_names) + + try: + if self.state.included_files[index]["name"] != uploaded_file["name"]: + self.state.included_files[index]["error"] = ( + "File name mismatch. Expected: " + + self.state.included_files[index]["name"] + ) + elif self.state.included_files[index]["content"]["size"] == 0: + self.state.included_files[index]["error"] = "File is empty." + else: + self.state.included_files[index]["error"] = None + self.state.included_files[index]["uploaded"] = True + except Exception: + self.state.included_files[index]["error"] = "Please upload a file." + self.state.included_files[index]["uploaded"] = False + self.state.dirty("included_files") + self.state.flush() + + @controller.set("confirm_included_files") + def confirm_included_files(self, **kwargs): + """Gets called when the Accept button in the included files dialog is + pressed. + + Saves all files into the temporary directory. + """ + self.state.include_upload_open = False + + for included_file in self.state.included_files: + # create file in temp directory + included_file_path = Path( + self._server_vars["temp_dir_object"].name, + included_file["content"]["name"], + ) + with open(included_file_path, "wb") as f: + f.write(included_file["content"]["content"]) + + self.init_state_and_server_vars() + + self.init_pyvista_render_objects() @change("export_fourc_yaml_path") def change_export_fourc_yaml_path(self, export_fourc_yaml_path, **kwargs): @@ -1176,9 +1371,6 @@ def change_selected_material(self, selected_material, **kwargs): # material (if we are not in an initial rendering scenario) if self._server_vars["render_count"]["change_selected_material"] > 0: # first get the master material id - master_mat_id = self.determine_master_mat_ind_for_material( - selected_material - ) # update plotter / render objects self.update_pyvista_render_objects() @@ -1251,10 +1443,14 @@ def change_selected_result_description_id( @change("selected_funct") def change_selected_funct(self, selected_funct, **kwargs): """Reaction to change of state.selected_funct.""" + # if there is no function_section + if not self.state.funct_section.get(selected_funct, {}): + return + # set the selected funct item to the first within the newly # selected funct self.state.selected_funct_item = next( - iter(self.state.funct_section[selected_funct]) + iter(self.state.funct_section.get(selected_funct, {})), ) # update plotly figure @@ -1279,18 +1475,22 @@ def change_selected_funct_item(self, selected_funct_item, **kwargs): def change_funct_plot(self, funct_plot, **kwargs): """Reaction to change of state.funct_plot.""" # update plotly figure - if self.state.funct_section[self.state.selected_funct][ - self.state.selected_funct_item - ]["VISUALIZATION"]: + if ( + self.state.funct_section.get(self.state.selected_funct, {}) + .get(self.state.selected_funct_item, {}) + .get("VISUALIZATION") + ): self.server.controller.figure_update(function_plot_figure(self.state)) @change("funct_section") def change_funct_section(self, funct_section, **kwargs): """Reaction to change of state.funct_section.""" # update plotly figure - if self.state.funct_section[self.state.selected_funct][ - self.state.selected_funct_item - ]["VISUALIZATION"]: + if ( + self.state.funct_section.get(self.state.selected_funct, {}) + .get(self.state.selected_funct_item, {}) + .get("VISUALIZATION") + ): self.server.controller.figure_update(function_plot_figure(self.state)) ################################################# @@ -1377,12 +1577,7 @@ def click_convert_button(self, **kwargs): # initialize state object self.init_state_and_server_vars() - # convert to vtu - fourc_geometry = FourCGeometry( - fourc_yaml_file=temp_fourc_yaml_file, - temp_dir=Path(self._server_vars["temp_dir_object"].name), - ) - self.state.vtu_path = fourc_geometry.vtu_file_path + self.init_pyvista_render_objects() # catch eventual conversion error if self.state.vtu_path == "": diff --git a/src/fourc_webviewer/gui_utils.py b/src/fourc_webviewer/gui_utils.py index 7654963..7cd2a2f 100644 --- a/src/fourc_webviewer/gui_utils.py +++ b/src/fourc_webviewer/gui_utils.py @@ -143,7 +143,7 @@ def _toolbar(server_controller): vuetify.VFileInput( label="Input file", v_model=("fourc_yaml_file",), - update_modelValue="flushState('fourc_yaml_file')", + # update_modelValue="flushState('fourc_yaml_file')", accept=".yaml,.yml", ) vuetify.VBtn( @@ -242,6 +242,58 @@ def _bottom_sheet_export(server_controller): ) +def _bottom_sheet_include_upload(server): + """Bottom sheet layout (EXPORT mode).""" + + with vuetify.VDialog( + v_model=("include_upload_open",), persistent=True, max_width="600px" + ): + with vuetify.VCard(classes="pa-5"): + vuetify.VCardTitle("Upload Included Files") + + with vuetify.VCardText(): + with vuetify.VRow( + dense=True, + align="center", + v_for="(file, i) in included_files", + key=("included_files[i].name",), + ): + with vuetify.VCol(cols=11): + vuetify.VFileInput( + update_modelValue=( + server.controller.on_upload_include_file, + "[$event, i]", + ), + label=("file.name",), + multiple=False, + variant="outlined", + color=( + "file.error ? 'error' : file.uploaded ? 'success' : undefined", + ), + error_messages=("file.error",), + ) + with vuetify.VCol(cols=1): + vuetify.VIcon( + icon=( + "file.error || !file.uploaded ? 'mdi-alert-circle' : 'mdi-check-circle'", + ), + color=( + "file.error ? 'error' : file.uploaded ? 'success' : 'primary'", + ), + classes="mr-2 pb-5 pl-3", + size="36", + ) + with vuetify.VCardActions(classes="justify-end"): + vuetify.VBtn( + "Accept", + size="large", + color="primary", + disabled=("!included_files.every(f => !f.error && f.uploaded)",), + click=(server.controller.confirm_included_files,), + variant="text", + ) + + def _sections_dropdown(): """Section dropdown layout.""" vuetify.VSelect( @@ -789,8 +841,7 @@ def _prop_value_table(server): "|| json_schema['properties']?.[selected_section_name]?.['properties']?.[add_key]?.['type'] == 'integer')" "&& !json_schema['properties']?.[selected_section_name]?.['properties']?.[add_key]?.['enum']" ), - blur=server.controller.on_leave_edit_field, - update_modelValue="flushState('general_sections')", # this is required in order to flush the state changes correctly to the server, as our passed on v-model is a nested variable + update_modelValue="flushState('add_value')", # this is required in order to flush the state changes correctly to the server, as our passed on v-model is a nested variable classes="w-80 pb-1", dense=True, # If we will add errors for this later @@ -810,7 +861,7 @@ def _prop_value_table(server): vuetify.VSwitch( v_model=("add_value"), classes="mt-4", - update_modelValue="flushState('general_sections')", + update_modelValue="flushState('add_value')", class_="mx-100", dense=True, color="primary", @@ -822,7 +873,7 @@ def _prop_value_table(server): "json_schema['properties']?.[selected_section_name]" "?.['properties']?.[add_key]?.['enum']" ), - update_modelValue="flushState('general_sections')", + update_modelValue="flushState('add_value')", # bind the enum array as items items=( "json_schema['properties'][selected_section_name]['properties'][add_key]['enum']", @@ -1446,6 +1497,8 @@ def create_gui(server, render_window): _bottom_sheet_info() _bottom_sheet_export(server.controller) + _bottom_sheet_include_upload(server) + with layout.drawer as drawer: drawer.width = 800 with html.Div(v_if=("vtu_path != ''",)): diff --git a/src/fourc_webviewer/input_file_utils/fourc_yaml_file_visualization.py b/src/fourc_webviewer/input_file_utils/fourc_yaml_file_visualization.py index 046e180..6e90a57 100644 --- a/src/fourc_webviewer/input_file_utils/fourc_yaml_file_visualization.py +++ b/src/fourc_webviewer/input_file_utils/fourc_yaml_file_visualization.py @@ -22,7 +22,9 @@ def get_variable_names_in_funct_expression(funct_expression: str): regular expressions.""" vars_found = re.findall(r"[A-Za-z_]+", funct_expression) return [ - v for v in vars_found if v not in DEF_FUNCT and v not in ["t", "x", "y", "z"] + v + for v in vars_found + if v not in DEF_FUNCT and v not in ["t", "x", "y", "z", "e", "E"] ]