├── .gitignore ├── LICENSE ├── README.md ├── api ├── __init__.py ├── comparators │ ├── __init__.py │ ├── hip_comparator.py │ └── houdini_base_comparator.py ├── data │ ├── __init__.py │ ├── item_data.py │ ├── node_data.py │ └── param_data.py └── utilities.py ├── changelog.rst ├── main.py ├── readme_images ├── difftool.png └── hip_file_diff_tool_preview2.gif ├── test ├── __init__.py ├── api │ ├── __init__.py │ ├── test_hip_file_comparator.py │ ├── test_node_data.py │ └── test_utilities.py └── fixtures │ ├── BoxHDA_edited.hda │ ├── BoxHDA_source.hda │ ├── billowy_smoke_source.hipnc │ ├── billowy_smoke_source_edited.hipnc │ ├── crown_splash_source_scene_w_created_parm.hipnc │ ├── crown_splash_source_scene_w_deleted_tw_parms_on_out_initial_particles_node.hipnc │ └── invalid_ext_file.txt └── ui ├── __init__.py ├── constants.py ├── custom_qtree_view.py ├── custom_standart_item_model.py ├── file_selector.py ├── hatched_pattern_item_delegate.py ├── hatched_text_edit.py ├── hip_file_diff_window.py ├── icons ├── IconMapping ├── closed.svg ├── empty.svg ├── end.svg ├── folder.png ├── icons.zip ├── more.svg ├── opened.svg ├── search.png └── vline.svg ├── recursive_filter_proxy_model.py ├── search_line_edit.py ├── string_diff_dialog.py └── ui_utils.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | __pycache__/ 3 | backup/ 4 | .vscode/ -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | The Houdini Hipfile Diff Tool, a difference viewer tailored for Houdini project files using hython 3.9. 2 | 3 | This tool offers dual-view comparisons, visual indicators for changes, synchronized navigation, and a search feature. It's designed to streamline the process of tracking changes in Houdini projects. It is very similar to the git diff tool in a way, but it allows you to have a node tree view difference. 4 | 5 | ![difftool_schreenshot](readme_images/difftool.png) 6 | 7 | Given its reliance on Houdini libraries, ensure you launch this tool using Houdini's specific Python distribution, hython3.9, bundled with each Houdini release. Typically, you can locate it at: ".../Side Effects Software/Houdini 19.5.368/bin/hython3.9.exe". 8 | 9 | ![difftool_gif](readme_images/hip_file_diff_tool_preview2.gif) 10 | 11 | To get started: 12 | 1. Clone the repository: git clone https://github.com/golubevcg/hip_file_diff_tool 13 | 2. Navigate to the repository folder and initiate the tool with Houdini's hython3.9: 14 | 15 | ```console 16 | ".../Side Effects Software/Houdini 19.5.368/bin/hython3.9.exe" .../hip_file_diff_tool/main.py 17 | ``` 18 | 19 | (Ensure you replace '...' with the full path to hython and main.py) 20 | 21 | For unit testing, execute: 22 | hython3.9.exe -m unittest discover -p 'test*.py' 23 | from the repository's root directory. 24 | 25 | Some additional articles about the tool's functionality can be found on my website:
26 | [Comparing Hipfiles in Houdini: The Diff Tool](https://golubevcg.com/post/comparing_hipfiles_in_houdini:_the_diff_tool)
27 | [What's New in Hipfile Diff Tool v1.1 Update](https://golubevcg.com/post/what's_new_in_my_hipfile_diff_tool_v1.1_update)
28 | -------------------------------------------------------------------------------- /api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/golubevcg/hip_file_diff_tool/fb5de338d53cf69b8bd857e65f048c740b3a20f2/api/__init__.py -------------------------------------------------------------------------------- /api/comparators/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/golubevcg/hip_file_diff_tool/fb5de338d53cf69b8bd857e65f048c740b3a20f2/api/comparators/__init__.py -------------------------------------------------------------------------------- /api/comparators/hip_comparator.py: -------------------------------------------------------------------------------- 1 | import hou 2 | from api.comparators.houdini_base_comparator import HoudiniComparator 3 | 4 | 5 | class HipFileComparator(HoudiniComparator): 6 | """Comparator class for comparing two Houdini HIP files.""" 7 | 8 | def get_hip_data(self, hip_path: str) -> dict: 9 | """ 10 | Retrieve data from a given HIP file. 11 | 12 | :param hip_path: The path to the HIP file. 13 | :return: A dictionary containing data extracted from the HIP file. 14 | """ 15 | if not hip_path: 16 | raise ValueError("No source file specified!") 17 | 18 | self._load_hip_file(hip_path) 19 | data_dict = {} 20 | for node in hou.node("/").allNodes(): 21 | if node.isInsideLockedHDA(): 22 | continue 23 | data_dict[node.path()] = self._extract_node_data(node) 24 | 25 | return data_dict 26 | 27 | def _load_hip_file(self, hip_path: str) -> None: 28 | """Load a specified HIP file into Houdini.""" 29 | hou.hipFile.clear() 30 | hou.hipFile.load( 31 | hip_path, suppress_save_prompt=True, ignore_load_warnings=True 32 | ) 33 | 34 | def compare(self) -> None: 35 | """Compare the source and target HIP files to identify differences.""" 36 | self._validate_file_paths() 37 | 38 | self.source_nodes = self.get_hip_data(self.source_file) 39 | self.target_nodes = self.get_hip_data(self.target_file) 40 | 41 | self._handle_deleted_and_edited_nodes() 42 | self._handle_created_nodes() 43 | self._handle_created_params() 44 | 45 | self.source_data = self.source_nodes 46 | self.target_data = self.target_nodes 47 | 48 | self.is_compared = True 49 | -------------------------------------------------------------------------------- /api/comparators/houdini_base_comparator.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from collections import OrderedDict 3 | import os 4 | 5 | from api.data.item_data import ItemState 6 | from api.data.node_data import NodeData 7 | from api.data.param_data import ParamData 8 | from api.utilities import ordered_dict_insert, get_ordered_dict_key_index 9 | 10 | import hou 11 | 12 | 13 | COLORS = { 14 | "red": "#b50400", 15 | "green": "#6ba100", 16 | } 17 | 18 | HIP_FILE_FORMATS = {"hip", "hipnc", "hiplc", "hdt"} 19 | 20 | 21 | class HoudiniComparator(ABC): 22 | """Comparator class for comparing two Houdini related files.""" 23 | def __init__(self, source_file: str, target_file: str): 24 | """ 25 | Initialize the comparator with source and target files. 26 | 27 | :param source_file: Path to the source file. 28 | :param target_file: Path to the target file. 29 | """ 30 | self.source_file = source_file 31 | self.target_file = target_file 32 | 33 | self.source_nodes = OrderedDict() 34 | self.target_nodes = OrderedDict() 35 | self.diff_nodes = OrderedDict() 36 | 37 | self.source_data = OrderedDict() 38 | self.target_data = OrderedDict() 39 | 40 | self.is_compared = False 41 | 42 | @property 43 | def source_file(self): 44 | return self._source_file 45 | 46 | @source_file.setter 47 | def source_file(self, value): 48 | self._check_file_path(value, "source") 49 | self._source_file = value 50 | 51 | @property 52 | def target_file(self): 53 | return self._target_file 54 | 55 | @target_file.setter 56 | def target_file(self, value): 57 | self._check_file_path(value, "target") 58 | self._target_file = value 59 | 60 | def _check_file_path(self, path: str, file_type: str) -> None: 61 | """Check if the provided path is valid and of a supported format.""" 62 | if not path or not os.path.exists(path): 63 | raise RuntimeError( 64 | f"Incorrect {file_type} path specified. " 65 | "Such file doesn't exist." 66 | ) 67 | 68 | _, extension = os.path.splitext(path) 69 | if not extension or extension[1:] not in HIP_FILE_FORMATS: 70 | raise RuntimeError( 71 | f"Incorrect {file_type} file format. " 72 | "Supported formats are: {', '.join(HIP_FILE_FORMATS)}." 73 | ) 74 | 75 | def _extract_node_data(self, node: hou.Node) -> NodeData: 76 | """ 77 | Extracts data from a given node. 78 | 79 | :param node: The node from which to extract data. 80 | :return: A NodeData object containing extracted data. 81 | """ 82 | node_data = NodeData(node.name()) 83 | node_data.path = node.path() 84 | node_data.type = node.type() 85 | node_data.icon = node.type().icon() 86 | node_data.parent_path = self._get_parent_path(node) 87 | 88 | input_connections = [inp_node.name() for inp_node in node.inputs() if inp_node] 89 | if input_connections: 90 | inp_conn_param = ParamData( 91 | "-> input connections", 92 | ", ".join(input_connections), 93 | None 94 | ) 95 | inp_conn_param.icon = False 96 | node_data.add_parm( 97 | "-> input connections", 98 | inp_conn_param 99 | ) 100 | 101 | user_data = node.userDataDict() 102 | param_user_data = ParamData("userData", None, None) 103 | if user_data: 104 | param_user_data.value = user_data 105 | node_data.user_data = param_user_data 106 | 107 | for parm in node.parms(): 108 | node_data.add_parm( 109 | parm.name(), ParamData(parm.name(), parm.eval(), None) 110 | ) 111 | 112 | return node_data 113 | 114 | def _validate_file_paths(self) -> None: 115 | """Validate that both source and target file paths are set.""" 116 | if not self.source_file: 117 | raise ValueError("Error, no source file specified!") 118 | if not self.target_file: 119 | raise ValueError("Error, no target file specified!") 120 | 121 | def _mark_node_as_created(self, path: str): 122 | """ 123 | Mark a node as created and update source data accordingly. 124 | 125 | :param path: The path of the node. 126 | """ 127 | new_data = NodeData("") 128 | new_data.parent_path = self.target_nodes[path].parent_path 129 | new_data.state = ItemState.CREATED 130 | index = get_ordered_dict_key_index(self.target_nodes, path) 131 | 132 | self.source_nodes = ordered_dict_insert( 133 | self.source_nodes, index, path, new_data 134 | ) 135 | self.source_nodes[path].alpha = 55 136 | self.source_nodes[path].is_hatched = True 137 | 138 | self.target_nodes[path].state = ItemState.CREATED 139 | self.target_nodes[path].color = COLORS["green"] 140 | self.target_nodes[path].alpha = 55 141 | 142 | def _mark_node_as_deleted(self, path: str, source_node_data): 143 | """ 144 | Mark a node as deleted and update target data accordingly. 145 | 146 | :param path: The path of the node. 147 | :param source_node_data: The data associated with the source node. 148 | """ 149 | new_data = NodeData("") 150 | new_data.parent_path = source_node_data.parent_path 151 | new_data.state = ItemState.DELETED 152 | new_data.is_hatched = True 153 | index = get_ordered_dict_key_index(self.source_nodes, path) 154 | self.target_nodes = ordered_dict_insert( 155 | self.target_nodes, index, path, new_data 156 | ) 157 | 158 | source_node_data.state = ItemState.DELETED 159 | source_node_data.color = COLORS["red"] 160 | source_node_data.alpha = 100 161 | 162 | def _handle_deleted_and_edited_nodes(self): 163 | """Handle nodes that are deleted or have edited parameters.""" 164 | for path, source_node_data in self.source_nodes.items(): 165 | if path not in self.target_nodes: 166 | self._mark_node_as_deleted(path, source_node_data) 167 | else: 168 | self._compare_node_user_data(path, source_node_data) 169 | self._compare_node_params(path, source_node_data) 170 | 171 | def _compare_node_params(self, path: str, source_node_data: NodeData): 172 | """ 173 | Compare parameters of nodes between source and target data. 174 | 175 | :param path: The path of the node. 176 | :param source_node_data: The data associated with the source node. 177 | """ 178 | for parm_name in list(source_node_data.parms): 179 | source_parm = source_node_data.get_parm_by_name(parm_name) 180 | 181 | # deleted param 182 | if parm_name not in self.target_nodes[path].parms: 183 | # add empty parm to target data 184 | self.source_nodes[path].state = ItemState.EDITED 185 | self.source_nodes[path].color = COLORS["red"] 186 | self.source_nodes[path].alpha = 100 187 | 188 | source_parm = self.source_nodes[path].get_parm_by_name( 189 | parm_name 190 | ) 191 | source_parm.state = ItemState.EDITED 192 | source_parm.color = "red" 193 | source_parm.alpha = 55 194 | 195 | self.source_nodes[path].state = ItemState.EDITED 196 | self.target_nodes[path].color = COLORS["red"] 197 | self.target_nodes[path].alpha = 100 198 | 199 | parm = ParamData(parm_name, "", ItemState.DELETED) 200 | parm.alpha = 55 201 | parm.is_active = False 202 | parm.is_hatched = True 203 | 204 | self.target_nodes[path].add_parm(parm_name, parm) 205 | continue 206 | 207 | target_parm = self.target_nodes[path].get_parm_by_name(parm_name) 208 | 209 | if str(source_parm.value) == str(target_parm.value): 210 | continue 211 | 212 | source_parm.state = ItemState.EDITED 213 | source_parm.color = COLORS["red"] 214 | source_parm.alpha = 55 215 | 216 | source_node_data.state = ItemState.EDITED 217 | source_node_data.color = COLORS["red"] 218 | source_node_data.alpha = 100 219 | 220 | target_parm.state = ItemState.EDITED 221 | target_parm.color = COLORS["green"] 222 | target_parm.alpha = 55 223 | 224 | self.target_nodes[path].state = ItemState.EDITED 225 | self.target_nodes[path].color = COLORS["green"] 226 | self.target_nodes[path].alpha = 100 227 | 228 | def _compare_node_user_data(self, path: str, source_node_data: NodeData): 229 | """ 230 | Compare userData dict of nodes between source and target data. 231 | 232 | :param path: The path of the node. 233 | :param source_node_data: The data associated with the source node. 234 | """ 235 | source_user_data_parm = source_node_data.user_data 236 | 237 | node_from_target_scene = self.target_nodes[path] 238 | target_user_data_parm = node_from_target_scene.user_data 239 | 240 | if source_user_data_parm.value == target_user_data_parm.value: 241 | return 242 | 243 | source_user_data_parm.state = ItemState.EDITED 244 | source_user_data_parm.alpha = 100 245 | source_user_data_parm.color = COLORS["red"] 246 | self.source_nodes[path].user_data = source_user_data_parm 247 | 248 | target_user_data_parm.state = ItemState.EDITED 249 | target_user_data_parm.alpha = 55 250 | target_user_data_parm.color = COLORS["green"] 251 | self.target_nodes[path].user_data = target_user_data_parm 252 | 253 | def _handle_created_params(self): 254 | """Handle items for node params that are newly created.""" 255 | for path, target_data in self.target_nodes.items(): 256 | for parm_name in list(target_data.parms): 257 | if parm_name in self.source_nodes[path].parms: 258 | continue 259 | 260 | # created param 261 | target_parm = target_data.get_parm_by_name(parm_name) 262 | target_parm.state = ItemState.CREATED 263 | target_parm.color = COLORS["green"] 264 | target_parm.alpha = 55 265 | 266 | target_data.state = ItemState.EDITED 267 | target_data.color = COLORS["green"] 268 | target_data.alpha = 100 269 | 270 | parm = ParamData(parm_name, "", ItemState.CREATED) 271 | parm.alpha = 55 272 | parm.is_hatched = True 273 | parm.is_active = False 274 | 275 | self.source_nodes[path].add_parm(parm_name, parm) 276 | 277 | self.source_nodes[path].state = ItemState.EDITED 278 | self.source_nodes[path].alpha = 100 279 | 280 | def _handle_created_nodes(self): 281 | """Handle nodes that are newly created.""" 282 | source_paths = set(self.source_nodes.keys()) 283 | target_paths = set(self.target_nodes.keys()) 284 | 285 | # Faster set difference operation 286 | for path in (target_paths - source_paths): 287 | self._mark_node_as_created(path) 288 | 289 | def _get_parent_path(self, node) -> str: 290 | """Return the path of a node's parent or None if no parent is found.""" 291 | try: 292 | return node.parent().path() 293 | except AttributeError: 294 | return None 295 | 296 | @abstractmethod 297 | def compare(self) -> None: 298 | """ 299 | Abstract method for comparing the source and target data structures. 300 | To be implemented by the child classes. 301 | """ 302 | raise NotImplementedError( 303 | "The compare method is an abstract one and should be implemented." 304 | ) 305 | -------------------------------------------------------------------------------- /api/data/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/golubevcg/hip_file_diff_tool/fb5de338d53cf69b8bd857e65f048c740b3a20f2/api/data/__init__.py -------------------------------------------------------------------------------- /api/data/item_data.py: -------------------------------------------------------------------------------- 1 | from collections import OrderedDict 2 | from enum import auto, Enum 3 | from typing import Any, Optional 4 | from dataclasses import dataclass, field 5 | 6 | 7 | class ItemState(Enum): 8 | """ 9 | An enum representing the diff state of a item. 10 | """ 11 | UNCHANGED = None 12 | EDITED = auto() 13 | DELETED = auto() 14 | CREATED = auto() 15 | VALUE = auto() 16 | 17 | def __str__(self): 18 | return f"{self.name.lower()}" 19 | 20 | def __format__(self, spec): 21 | return f"{self.name.lower()}" 22 | 23 | @dataclass 24 | class ItemData: 25 | """ 26 | A class to represent default item data which is required for the UI. 27 | """ 28 | name: str # The only required argument in the constructor 29 | path: Optional[str] = None 30 | type: str = "" 31 | icon: str = "" 32 | state: ItemState = ItemState.UNCHANGED 33 | parent_path: str = "" 34 | color: Optional[str] = None 35 | alpha: int = 255 36 | is_hatched: bool = False 37 | parms: OrderedDict[str, Any] = field(default_factory=OrderedDict) 38 | user_data: OrderedDict = field(default_factory=OrderedDict) -------------------------------------------------------------------------------- /api/data/node_data.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, field 2 | from typing import Any 3 | from api.data.item_data import ItemData 4 | 5 | 6 | @dataclass 7 | class NodeData(ItemData): 8 | """ 9 | A class to represent some of the Houdini node data. 10 | """ 11 | 12 | def __init__(self, name: str): 13 | """ 14 | Initialize a new instance of the NodeData class. 15 | 16 | :param name: The name of the node. 17 | """ 18 | super().__init__(name) 19 | 20 | def add_parm(self, name: str, param: Any) -> None: 21 | """ 22 | Add a parameter to the node's parameter dictionary. 23 | 24 | :param name: The name of the parameter. 25 | :param param: The parameter data to be added. 26 | """ 27 | self.parms[name] = param 28 | 29 | def get_parm_by_name(self, name: str) -> Any: 30 | """ 31 | Retrieve a parameter by its name from the node's parameter dictionary. 32 | 33 | :param name: The name of the parameter to be retrieved. 34 | :return: The parameter data associated with the provided name. 35 | :raises ValueError: If the parameter name is not found 36 | in the dictionary. 37 | """ 38 | if name not in self.parms: 39 | raise ValueError( 40 | f"Parameter '{name}' is not found in the dictionary." 41 | ) 42 | 43 | return self.parms[name] 44 | 45 | def __repr__(self): 46 | return f"{self.name}: {self.state}\n" 47 | -------------------------------------------------------------------------------- /api/data/param_data.py: -------------------------------------------------------------------------------- 1 | from enum import auto, Enum 2 | from collections import OrderedDict 3 | from api.data.item_data import ItemState 4 | 5 | 6 | class ParamData: 7 | """ 8 | A class to represent parameter data associated with a node. 9 | 10 | Attributes: 11 | name (str): The name identifier for the parameter. 12 | value: The value associated with the parameter. 13 | state (ItemState): The state of the parameter. 14 | ItemState.UNCHANGED by default. 15 | is_active (bool): Indicates whether the parameter is active. 16 | True by default. 17 | color (Optional[str]): The color associated with the parameter. 18 | None by default. 19 | alpha (int): The opacity value (0-255) for the parameter visualization. 20 | 255 by default. 21 | is_hatched (bool): Indicates whether the parameter has 22 | a hatched pattern. False by default. 23 | """ 24 | 25 | def __init__( 26 | self, 27 | name: str, 28 | value: str, 29 | state: ItemState = ItemState.UNCHANGED, 30 | color: str = None, 31 | alpha: int = 255, 32 | is_hatched: bool = False, 33 | icon: bool = True, 34 | ): 35 | """ 36 | Initialize a new instance of the ParamData class. 37 | 38 | :param name: The name identifier for the parameter. 39 | :param value: The value associated with the parameter. 40 | :param state: A state for the parameter. Default is ItemState.UNCHANGED. 41 | :param color: The color associated with the parameter. 42 | Default is None. 43 | :param alpha: The opacity value for the parameter visualization. 44 | Default is 255. 45 | :param is_hatched: Indicates whether the parameter has 46 | a hatched pattern. Default is False. 47 | """ 48 | self.name = name 49 | self.value = value 50 | 51 | self.state = state 52 | self.is_active = True 53 | self.color = color 54 | self.alpha = alpha 55 | self.is_hatched = is_hatched 56 | self.icon = icon 57 | 58 | @property 59 | def value(self): 60 | return self._value 61 | 62 | @value.setter 63 | def value(self, value): 64 | if value and type(value) in (dict, OrderedDict): 65 | self._value = "\n".join(f"{key}: {value}" for key, value in value.items()) 66 | else: 67 | self._value = value 68 | 69 | def __repr__(self): 70 | return f"ParamData(\ 71 | name={self.name!r}, \ 72 | value={self.value!r}, \ 73 | state={self.state!r}, \ 74 | color={self.color!r}, \ 75 | alpha={self.alpha}, \ 76 | is_hatched={self.is_hatched}\ 77 | )" 78 | 79 | def __str__(self): 80 | return f"{self.name}: {self.state}" 81 | -------------------------------------------------------------------------------- /api/utilities.py: -------------------------------------------------------------------------------- 1 | from collections import OrderedDict 2 | import difflib 3 | from typing import List, TypeVar 4 | 5 | 6 | K = TypeVar("K") 7 | V = TypeVar("V") 8 | 9 | 10 | def ordered_dict_insert( 11 | d: OrderedDict[K, V], index: int, key: K, value: V 12 | ) -> OrderedDict[K, V]: 13 | """ 14 | Insert a key-value pair into an OrderedDict at a specified index. 15 | 16 | :param d: The dictionary into which to insert. 17 | :param index: The position at which to insert the key-value pair. 18 | :param key: The key to insert. 19 | :param value: The corresponding value to insert. 20 | :return: A new OrderedDict with the key-value pair inserted. 21 | """ 22 | before = list(d.items())[:index] 23 | after = list(d.items())[index:] 24 | before.append((key, value)) 25 | return OrderedDict(before + after) 26 | 27 | 28 | def get_ordered_dict_key_index( 29 | ordered_dict: OrderedDict[K, V], target_key: K 30 | ) -> int: 31 | """ 32 | Return the index of a key in an OrderedDict. 33 | 34 | :param ordered_dict: The dictionary to search in. 35 | :param target_key: The key to find the index for. 36 | :return: The index of the target_key if found, raises an error otherwise. 37 | """ 38 | for idx, key in enumerate(ordered_dict): 39 | if key == target_key: 40 | return idx 41 | raise KeyError(f"'{target_key}' not found in the OrderedDict.") 42 | 43 | 44 | def file_diff(file_path_a: str, file_path_b: str) -> List[str]: 45 | with open(file_path_a, "r") as file_a, open(file_path_b, "r") as file_b: 46 | file_diff_list = [ 47 | i 48 | for i in difflib.unified_diff( 49 | file_a.readlines(), file_b.readlines(), lineterm="" 50 | ) 51 | ] 52 | 53 | return file_diff_list 54 | 55 | 56 | def string_diff(string_a: str, string_b: str) -> List[str]: 57 | string_lines_a = string_a.splitlines() 58 | string_lines_b = string_b.splitlines() 59 | diff_list = [ 60 | i for i in difflib.unified_diff(string_lines_a, string_lines_b, lineterm="") 61 | ] 62 | 63 | return diff_list 64 | -------------------------------------------------------------------------------- /changelog.rst: -------------------------------------------------------------------------------- 1 | Changelog 2 | ========= 3 | 4 | Version 1.1 (07 Jan 2024) 5 | -------------- 6 | * Added per node input connections for diff; 7 | * Added support for per node userDataDict diff (if there are any); 8 | * New window with string diff for longer strings, like scripts or dicts; 9 | * Added option to share a link to a specific node or string diff window with environment variable setup for studio workflows 10 | (you can specify the environment variable with the path to Houdini's hython to launch tool); 11 | * Show only edited now checked by default; 12 | * Significant improvements and refactorings of the API; 13 | * Improvements in API unit testing coverage. 14 | 15 | Version 1.0 (17 Sep 2023) 16 | ------------------------ 17 | * Initial release 18 | 19 | 20 | History 21 | ------- 22 | The project was started in September 2023 by Andrew Golubev. -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import os 3 | import argparse 4 | 5 | from ui.hip_file_diff_window import HipFileDiffWindow 6 | 7 | from hutil.Qt.QtWidgets import QApplication 8 | from hutil.Qt.QtCore import Qt 9 | 10 | 11 | def main(): 12 | parser = argparse.ArgumentParser(description="Command-line parser for given parameters.") 13 | 14 | # Argument for 'executable_path' 15 | parser.add_argument("-e", "--executable", dest="executable_path", 16 | help="Path to the executable.") 17 | 18 | # Argument for 'source_file_path' 19 | parser.add_argument("-s", "--source", dest="source_file_path", 20 | help="Path to the source file.") 21 | 22 | # Argument for 'target_file_path' 23 | parser.add_argument("-t", "--target", dest="target_file_path", 24 | help="Path to the target file.") 25 | 26 | # Argument for 'item_path' 27 | parser.add_argument("-i", "--item-path", dest="item_path", 28 | help="Path to the item to open in string diff.") 29 | 30 | args = parser.parse_args() 31 | 32 | main_path = os.path.abspath(__file__) 33 | args.main_path = main_path 34 | 35 | QApplication.setAttribute(Qt.AA_UseHighDpiPixmaps) 36 | QApplication.setAttribute(Qt.AA_EnableHighDpiScaling, False) 37 | app = QApplication(sys.argv) 38 | window = HipFileDiffWindow(args) 39 | window.show() 40 | sys.exit(app.exec_()) 41 | 42 | 43 | if __name__ == "__main__": 44 | main() 45 | -------------------------------------------------------------------------------- /readme_images/difftool.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/golubevcg/hip_file_diff_tool/fb5de338d53cf69b8bd857e65f048c740b3a20f2/readme_images/difftool.png -------------------------------------------------------------------------------- /readme_images/hip_file_diff_tool_preview2.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/golubevcg/hip_file_diff_tool/fb5de338d53cf69b8bd857e65f048c740b3a20f2/readme_images/hip_file_diff_tool_preview2.gif -------------------------------------------------------------------------------- /test/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/golubevcg/hip_file_diff_tool/fb5de338d53cf69b8bd857e65f048c740b3a20f2/test/__init__.py -------------------------------------------------------------------------------- /test/api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/golubevcg/hip_file_diff_tool/fb5de338d53cf69b8bd857e65f048c740b3a20f2/test/api/__init__.py -------------------------------------------------------------------------------- /test/api/test_hip_file_comparator.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest.mock import patch, Mock 3 | 4 | from api.comparators.hip_comparator import HipFileComparator 5 | from api.utilities import COLORS 6 | from api.data.item_data import ItemState 7 | from api.data.node_data import NodeData 8 | from api.data.param_data import ParamData 9 | import hou 10 | 11 | 12 | class TestHipFileComparator(unittest.TestCase): 13 | SOURCE_HIP_FILE = "test/fixtures/billowy_smoke_source.hipnc" 14 | TARGET_HIP_FILE = "test/fixtures/billowy_smoke_source_edited.hipnc" 15 | 16 | def setUp(self): 17 | # Create some dummy paths 18 | self.invalid_ext_path = "test/fixtures/invalid_ext_file.txt" 19 | self.nonexistent_path = "test/fixtures/nonexistent/file.hip" 20 | self.hip_comparator = HipFileComparator( 21 | self.SOURCE_HIP_FILE, self.TARGET_HIP_FILE 22 | ) 23 | 24 | @patch("api.hip_file_comparator.hou") 25 | def test_check_file_path_valid(self, mock_hou): 26 | """Test _check_file_path with a valid HIP file.""" 27 | comparator = HipFileComparator( 28 | self.SOURCE_HIP_FILE, self.SOURCE_HIP_FILE 29 | ) 30 | # No exception should be raised 31 | comparator._check_file_path(self.SOURCE_HIP_FILE, "source") 32 | 33 | @patch("api.hip_file_comparator.hou") 34 | def test_check_hip_file_path_invalid_extension(self, mock_hou): 35 | """ 36 | Test HipFileComparator._check_file_path with an invalid file extension. 37 | """ 38 | comparator = HipFileComparator( 39 | self.SOURCE_HIP_FILE, self.SOURCE_HIP_FILE 40 | ) 41 | with self.assertRaises(RuntimeError): 42 | comparator._check_file_path(self.invalid_ext_path, "source") 43 | comparator._check_file_path(self.SOURCE_HDA_FILE, "source") 44 | 45 | @patch("api.hip_file_comparator.hou") 46 | def test_check_file_path_nonexistent_file(self, mock_hou): 47 | """Test _check_file_path with a nonexistent file.""" 48 | comparator = HipFileComparator( 49 | self.SOURCE_HIP_FILE, self.SOURCE_HIP_FILE 50 | ) 51 | with self.assertRaises(RuntimeError): 52 | comparator._check_file_path(self.nonexistent_path, "source") 53 | 54 | @patch("api.hip_file_comparator.hou") 55 | def test_get_hip_data_empty_path(self, mock_hou): 56 | """Test get_hip_data with an empty path.""" 57 | with self.assertRaises(ValueError): 58 | self.hip_comparator.get_hip_data("") 59 | 60 | @patch("api.hip_file_comparator.hou") 61 | def test_get_hip_data_valid(self, mock_hou): 62 | """Test get_hip_data with a valid HIP file.""" 63 | 64 | # Mocking the behavior of hou.node("/").allNodes() 65 | mock_node = Mock() 66 | mock_node.isInsideLockedHDA.return_value = False 67 | mock_node.path.return_value = "/mock_path" 68 | mock_node.name.return_value = "mock_name" 69 | 70 | mock_node_type = Mock() 71 | mock_node_type.icon.return_value = "mock_icon" 72 | mock_node.type.return_value = mock_node_type 73 | 74 | mock_parm = Mock() 75 | mock_parm.name.return_value = "mock_parm_name" 76 | mock_parm.eval.return_value = "mock_value" 77 | mock_node.parms.return_value = [mock_parm] 78 | 79 | mock_hou.node.return_value.allNodes.return_value = [mock_node] 80 | 81 | result = self.hip_comparator.get_hip_data(self.SOURCE_HIP_FILE) 82 | self.assertIn("/mock_path", result) 83 | 84 | @patch("api.hip_file_comparator.hou.hipFile.clear") 85 | @patch("api.hip_file_comparator.hou.hipFile.load") 86 | def test_load_hip_file_clears_and_loads(self, mock_load, mock_clear): 87 | self.hip_comparator._load_hip_file(self.SOURCE_HIP_FILE) 88 | 89 | mock_clear.assert_called_once() 90 | mock_load.assert_called_once_with( 91 | self.SOURCE_HIP_FILE, 92 | suppress_save_prompt=True, 93 | ignore_load_warnings=True, 94 | ) 95 | 96 | @patch("api.hip_file_comparator.hou.hipFile.load") 97 | def test_load_hip_file_raises_exception_on_failure(self, mock_load): 98 | mock_load.side_effect = Exception("Failed to load") 99 | 100 | with self.assertRaises(Exception) as context: 101 | self.hip_comparator._load_hip_file(self.SOURCE_HIP_FILE) 102 | 103 | self.assertEqual(str(context.exception), "Failed to load") 104 | 105 | @patch("api.hip_file_comparator.NodeData") 106 | def test_extract_node_data(self, MockNodeData): 107 | # Mocking the node and its properties 108 | mock_node = Mock() 109 | mock_node.name.return_value = "mock_node" 110 | mock_node.path.return_value = "/path/to/mock_node" 111 | mock_node.type.icon.return_value = ( 112 | "mock_icon" # Adjusted to handle the method chain 113 | ) 114 | mock_node.type.return_value = mock_node 115 | 116 | # Mocking _get_parent_path on the instance 117 | self.hip_comparator._get_parent_path = Mock(return_value="/path/to/parent") 118 | 119 | # Mocking the node's parameters 120 | mock_parm = Mock() 121 | mock_parm.name.return_value = "mock_parm_name" 122 | mock_parm.eval.return_value = "mock_parm_value" 123 | mock_node.parms.return_value = [mock_parm] 124 | 125 | # Call the method under test 126 | result = self.hip_comparator._extract_node_data(mock_node) 127 | 128 | # Assertions 129 | MockNodeData.assert_called_once_with("mock_node") 130 | mock_node_data_instance = MockNodeData.return_value 131 | mock_node_data_instance.add_parm.assert_called_once_with( 132 | "mock_parm_name", mock_node_data_instance.add_parm.call_args[0][1] 133 | ) 134 | self.assertEqual(result, mock_node_data_instance) 135 | 136 | def test_get_parent_path_with_empty_path(self): 137 | # This test assumes that passing an empty path will raise a ValueError. 138 | self.hip_comparator._get_parent_path("") 139 | 140 | def test_extract_node_data_from_hip(self): 141 | self.hip_comparator._load_hip_file(self.SOURCE_HIP_FILE) 142 | node = hou.node('/obj/billowy_smoke') 143 | node_data = self.hip_comparator._extract_node_data(node) 144 | 145 | self.assertEqual(node_data.name, node.name()) 146 | self.assertEqual(node_data.path, node.path()) 147 | self.assertEqual(node_data.type, node.type()) 148 | self.assertEqual(node_data.icon, node.type().icon()) 149 | self.assertEqual(node_data.parent_path, self.hip_comparator._get_parent_path(node)) 150 | 151 | for parm in node.parms(): 152 | param_data = node_data.get_parm_by_name(parm.name()) 153 | self.assertIsNotNone(param_data) 154 | self.assertEqual(param_data.name, parm.name()) 155 | self.assertEqual(param_data.value, parm.eval()) 156 | 157 | def test_validate_file_paths_both_set(self): 158 | try: 159 | self.hip_comparator._validate_file_paths() 160 | except ValueError as e: 161 | self.fail(f"_validate_file_paths raised ValueError unexpectedly: {e}") 162 | 163 | def test_update_source_file(self): 164 | test_hip_comparator = HipFileComparator( 165 | self.SOURCE_HIP_FILE, self.TARGET_HIP_FILE 166 | ) 167 | 168 | with self.assertRaises(RuntimeError) as context: 169 | test_hip_comparator.source_file = None 170 | 171 | self.assertEqual(str(context.exception), "Incorrect source path specified. Such file doesn't exist.") 172 | 173 | @patch("api.node_data.NodeData") 174 | @patch("api.param_data.ParamData") 175 | def test_compare_node_params_edited(self, MockParamData, MockNodeData): 176 | comparator = HipFileComparator( 177 | self.SOURCE_HIP_FILE, 178 | self.TARGET_HIP_FILE 179 | ) 180 | 181 | # Create mock objects for source and target nodes and params 182 | source_param = Mock() 183 | source_param.value = "source_value" 184 | 185 | source_param2 = Mock() 186 | source_param2.value = "value2" 187 | 188 | source_parms_dummy = { 189 | "test_param": source_param, 190 | "test_param2": source_param2 191 | } 192 | source_node_data = Mock(items=source_parms_dummy) 193 | 194 | # Set up the source node's params dictionary to simulate having a param 195 | source_node_data.parms = source_parms_dummy 196 | 197 | target_param = Mock() 198 | target_param.value = "target_value" 199 | 200 | target_param2 = Mock() 201 | target_param2.value = "value2" 202 | 203 | # Set up the target node's params dictionary to simulate having a param 204 | target_parms_dummy = { 205 | "test_param": source_param, 206 | "test_param2": target_param2 207 | } 208 | target_node_data = Mock(items=target_parms_dummy) 209 | target_node_data.parms = target_parms_dummy 210 | 211 | dummy_path = "dummy_path" 212 | # Set the source and target nodes in the comparator's internal dictionaries 213 | comparator.source_nodes = { 214 | dummy_path : source_node_data, 215 | } 216 | comparator.target_nodes = { 217 | dummy_path : target_node_data, 218 | } 219 | 220 | # Mock the ParamData object returned by get_parm_by_name 221 | source_node_data.get_parm_by_name.return_value = source_param 222 | target_node_data.get_parm_by_name.return_value = target_param 223 | 224 | # Call the method under test 225 | comparator._compare_node_params(dummy_path, source_node_data) 226 | 227 | # Assert that the state and visual properties are set as expected 228 | self.assertEqual( 229 | comparator.source_nodes[dummy_path].state, 230 | ItemState.EDITED 231 | ) 232 | self.assertEqual( 233 | comparator.source_nodes[dummy_path].color, 234 | COLORS["red"] 235 | ) 236 | self.assertEqual(comparator.source_nodes[dummy_path].alpha, 100) 237 | 238 | self.assertEqual( 239 | comparator.target_nodes[dummy_path].state, 240 | ItemState.EDITED 241 | ) 242 | self.assertEqual( 243 | comparator.target_nodes[dummy_path].color, 244 | COLORS["green"] 245 | ) 246 | self.assertEqual(comparator.target_nodes[dummy_path].alpha, 100) 247 | 248 | def test_mark_node_as_created(self): 249 | hip_comparator = HipFileComparator( 250 | self.SOURCE_HIP_FILE, self.TARGET_HIP_FILE 251 | ) 252 | 253 | target_node = NodeData("") 254 | target_node.parent_path = 'parent/path' 255 | 256 | node_path = 'dummy_path' 257 | hip_comparator.target_nodes[node_path] = target_node 258 | 259 | hip_comparator._mark_node_as_created(node_path) 260 | 261 | # Assert that a NodeData object is created with an empty string 262 | new_data = NodeData("") 263 | new_data.parent_path = 'parent/path' 264 | new_data.state = ItemState.CREATED 265 | new_data.alpha = 100 266 | new_data.is_hatched = True 267 | 268 | # Assert that the state, color, and alpha properties of the NodeData 269 | # object in self.target_nodes are updated correctly 270 | self.assertEqual( 271 | hip_comparator.target_nodes[node_path].state, ItemState.CREATED 272 | ) 273 | self.assertEqual( 274 | hip_comparator.target_nodes[node_path].color, COLORS["green"] 275 | ) 276 | self.assertEqual(hip_comparator.target_nodes[node_path].alpha, 100) 277 | 278 | def test_mark_node_as_deleted(self): 279 | hip_comparator = HipFileComparator( 280 | self.SOURCE_HIP_FILE, self.TARGET_HIP_FILE 281 | ) 282 | 283 | source_node_data = NodeData("") 284 | source_node_data.parent_path = 'parent/path' 285 | 286 | node_path = 'dummy_path' 287 | hip_comparator.source_nodes[node_path] = source_node_data 288 | 289 | hip_comparator._mark_node_as_deleted(node_path, source_node_data) 290 | 291 | # Assert that the state, color, 292 | # and alpha properties of the source_node_data 293 | # are updated correctly 294 | self.assertEqual( 295 | source_node_data.state, ItemState.DELETED 296 | ) 297 | self.assertEqual( 298 | source_node_data.color, COLORS["red"] 299 | ) 300 | self.assertEqual(source_node_data.alpha, 100) 301 | 302 | def test_handle_created_params(self): 303 | hip_comparator = HipFileComparator( 304 | self.SOURCE_HIP_FILE, self.TARGET_HIP_FILE 305 | ) 306 | 307 | # Creating mock nodes and params 308 | source_node = NodeData("") 309 | target_node = NodeData("") 310 | target_param = ParamData("new_param", "", "value") 311 | target_node.add_parm("new_param", target_param) 312 | 313 | # Adding the mock nodes to the comparator's internal dictionaries 314 | hip_comparator.source_nodes['/path'] = source_node 315 | hip_comparator.target_nodes['/path'] = target_node 316 | 317 | hip_comparator._handle_created_params() 318 | 319 | # Assert that the state, color, and alpha properties of the target param are updated 320 | self.assertEqual(target_param.state, ItemState.CREATED) 321 | self.assertEqual(target_param.color, COLORS["green"]) 322 | self.assertEqual(target_param.alpha, 55) 323 | 324 | # Assert that the state, color, and alpha properties of the target node are updated 325 | self.assertEqual(target_node.state, ItemState.EDITED) 326 | self.assertEqual(target_node.color, COLORS["green"]) 327 | self.assertEqual(target_node.alpha, 100) 328 | 329 | # Assert that the source node has the new param with the expected properties 330 | created_param = source_node.get_parm_by_name("new_param") 331 | self.assertIsNotNone(created_param) 332 | self.assertEqual(created_param.state, ItemState.CREATED) 333 | self.assertEqual(created_param.alpha, 55) 334 | self.assertTrue(created_param.is_hatched) 335 | self.assertFalse(created_param.is_active) 336 | 337 | # Assert that the state and alpha properties of the source node are updated 338 | self.assertEqual(source_node.state, ItemState.EDITED) 339 | self.assertEqual(source_node.alpha, 100) 340 | 341 | def test_compare(self): 342 | 343 | self.hip_comparator.compare() 344 | 345 | edited_node_path = "/obj/billowy_smoke/smoke_base" 346 | edted_param_name = "radx" 347 | source_parm_val = 1.0 348 | 349 | edited_source_node = self.hip_comparator.source_nodes[ 350 | edited_node_path 351 | ] 352 | 353 | edited_source_parm = edited_source_node.get_parm_by_name( 354 | edted_param_name 355 | ) 356 | 357 | target_parm_val = 2.0 358 | edited_target_node = self.hip_comparator.target_nodes[ 359 | edited_node_path 360 | ] 361 | edited_target_parm = edited_target_node.get_parm_by_name( 362 | edted_param_name 363 | ) 364 | 365 | self.assertEqual(edited_source_node.state, ItemState.EDITED) 366 | self.assertEqual(edited_source_node.color, COLORS["red"]) 367 | 368 | self.assertEqual(edited_target_node.state, ItemState.EDITED) 369 | self.assertEqual(edited_target_node.color, COLORS["green"]) 370 | 371 | self.assertEqual(edited_source_parm.value, source_parm_val) 372 | self.assertEqual(edited_target_parm.value, target_parm_val) 373 | 374 | self.assertEqual(edited_source_parm.state, ItemState.EDITED) 375 | self.assertEqual(edited_target_parm.state, ItemState.EDITED) 376 | 377 | self.assertEqual(edited_source_parm.color, COLORS["red"]) 378 | self.assertEqual(edited_source_parm.alpha, 55) 379 | 380 | self.assertEqual(edited_target_parm.color, COLORS["green"]) 381 | self.assertEqual(edited_target_parm.alpha, 55) 382 | 383 | created_node_path = "/obj/billowy_smoke/null1" 384 | create_node_parm = "copyinput" 385 | created_source_node = self.hip_comparator.source_nodes[ 386 | created_node_path 387 | ] 388 | created_source_parm = created_source_node.get_parm_by_name( 389 | create_node_parm 390 | ) 391 | 392 | self.assertEqual(created_source_node.name, "") 393 | self.assertEqual(created_source_node.state, ItemState.EDITED) 394 | self.assertEqual(created_source_node.is_hatched, True) 395 | 396 | self.assertEqual(created_source_parm.value, "") 397 | self.assertEqual(created_source_parm.state, ItemState.CREATED) 398 | self.assertEqual(created_source_parm.is_hatched, True) 399 | 400 | created_target_node = self.hip_comparator.target_nodes[ 401 | created_node_path 402 | ] 403 | created_target_parm = created_target_node.get_parm_by_name( 404 | create_node_parm 405 | ) 406 | 407 | self.assertEqual(created_target_node.name, "null1") 408 | self.assertEqual(created_target_node.state, ItemState.EDITED) 409 | self.assertEqual(created_target_parm.is_hatched, False) 410 | 411 | self.assertEqual(created_target_parm.value, 1) 412 | self.assertEqual(created_target_parm.state, ItemState.CREATED) 413 | self.assertEqual(created_target_parm.is_hatched, False) 414 | -------------------------------------------------------------------------------- /test/api/test_node_data.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from collections import OrderedDict 3 | from api.data.item_data import ItemState 4 | from api.data.node_data import NodeData 5 | 6 | 7 | class TestNodeData(unittest.TestCase): 8 | def setUp(self): 9 | self.node = NodeData(name="TestNode") 10 | 11 | def test_initialization(self): 12 | self.assertEqual(self.node.name, "TestNode") 13 | self.assertIsNone(self.node.path) 14 | self.assertEqual(self.node.type, "") 15 | self.assertEqual(self.node.icon, "") 16 | self.assertEqual(self.node.state, ItemState.UNCHANGED) 17 | self.assertEqual(self.node.parent_path, "") 18 | self.assertEqual(self.node.parms, OrderedDict()) 19 | self.assertIsNone(self.node.color) 20 | self.assertEqual(self.node.alpha, 255) 21 | self.assertFalse(self.node.is_hatched) 22 | 23 | def test_add_parm(self): 24 | self.node.add_parm("param1", 123) 25 | self.assertIn("param1", self.node.parms) 26 | self.assertEqual(self.node.parms["param1"], 123) 27 | 28 | def test_get_parm_by_name_existing(self): 29 | self.node.add_parm("param1", 123) 30 | self.assertEqual(self.node.get_parm_by_name("param1"), 123) 31 | 32 | def test_get_parm_by_name_non_existing(self): 33 | with self.assertRaises(ValueError): 34 | self.node.get_parm_by_name("non_existing") 35 | 36 | def test_repr(self): 37 | self.assertEqual(repr(self.node), "TestNode: unchanged\n") 38 | -------------------------------------------------------------------------------- /test/api/test_utilities.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from collections import OrderedDict 3 | from api.utilities import ordered_dict_insert, get_ordered_dict_key_index 4 | 5 | 6 | class TestUtilities(unittest.TestCase): 7 | def test_ordered_dict_insert(self): 8 | od = OrderedDict([("a", 1), ("b", 2), ("c", 3)]) 9 | 10 | # Insert in the middle 11 | od_new = ordered_dict_insert(od, 1, "x", 99) 12 | self.assertEqual( 13 | list(od_new.items()), [("a", 1), ("x", 99), ("b", 2), ("c", 3)] 14 | ) 15 | 16 | # Insert at the beginning 17 | od_new = ordered_dict_insert(od, 0, "x", 99) 18 | self.assertEqual( 19 | list(od_new.items()), [("x", 99), ("a", 1), ("b", 2), ("c", 3)] 20 | ) 21 | 22 | # Insert at the end 23 | od_new = ordered_dict_insert(od, 3, "x", 99) 24 | self.assertEqual( 25 | list(od_new.items()), [("a", 1), ("b", 2), ("c", 3), ("x", 99)] 26 | ) 27 | 28 | # Insert with negative index (similar to list behavior) 29 | od_new = ordered_dict_insert(od, -1, "x", 99) 30 | self.assertEqual( 31 | list(od_new.items()), [("a", 1), ("b", 2), ("x", 99), ("c", 3)] 32 | ) 33 | 34 | def test_get_ordered_dict_key_index(self): 35 | od = OrderedDict([("a", 1), ("b", 2), ("c", 3)]) 36 | 37 | # Check key index in the middle 38 | self.assertEqual(get_ordered_dict_key_index(od, "b"), 1) 39 | 40 | # Check key index at the start 41 | self.assertEqual(get_ordered_dict_key_index(od, "a"), 0) 42 | 43 | # Check key index at the end 44 | self.assertEqual(get_ordered_dict_key_index(od, "c"), 2) 45 | 46 | # Check for non-existing key 47 | with self.assertRaises(KeyError): 48 | get_ordered_dict_key_index(od, "x") 49 | -------------------------------------------------------------------------------- /test/fixtures/BoxHDA_edited.hda: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/golubevcg/hip_file_diff_tool/fb5de338d53cf69b8bd857e65f048c740b3a20f2/test/fixtures/BoxHDA_edited.hda -------------------------------------------------------------------------------- /test/fixtures/BoxHDA_source.hda: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/golubevcg/hip_file_diff_tool/fb5de338d53cf69b8bd857e65f048c740b3a20f2/test/fixtures/BoxHDA_source.hda -------------------------------------------------------------------------------- /test/fixtures/billowy_smoke_source.hipnc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/golubevcg/hip_file_diff_tool/fb5de338d53cf69b8bd857e65f048c740b3a20f2/test/fixtures/billowy_smoke_source.hipnc -------------------------------------------------------------------------------- /test/fixtures/billowy_smoke_source_edited.hipnc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/golubevcg/hip_file_diff_tool/fb5de338d53cf69b8bd857e65f048c740b3a20f2/test/fixtures/billowy_smoke_source_edited.hipnc -------------------------------------------------------------------------------- /test/fixtures/crown_splash_source_scene_w_created_parm.hipnc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/golubevcg/hip_file_diff_tool/fb5de338d53cf69b8bd857e65f048c740b3a20f2/test/fixtures/crown_splash_source_scene_w_created_parm.hipnc -------------------------------------------------------------------------------- /test/fixtures/crown_splash_source_scene_w_deleted_tw_parms_on_out_initial_particles_node.hipnc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/golubevcg/hip_file_diff_tool/fb5de338d53cf69b8bd857e65f048c740b3a20f2/test/fixtures/crown_splash_source_scene_w_deleted_tw_parms_on_out_initial_particles_node.hipnc -------------------------------------------------------------------------------- /test/fixtures/invalid_ext_file.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/golubevcg/hip_file_diff_tool/fb5de338d53cf69b8bd857e65f048c740b3a20f2/test/fixtures/invalid_ext_file.txt -------------------------------------------------------------------------------- /ui/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/golubevcg/hip_file_diff_tool/fb5de338d53cf69b8bd857e65f048c740b3a20f2/ui/__init__.py -------------------------------------------------------------------------------- /ui/constants.py: -------------------------------------------------------------------------------- 1 | import os 2 | from typing import Dict 3 | 4 | from hutil.Qt.QtCore import Qt 5 | 6 | 7 | """ 8 | This module provides utilities for handling some constants. 9 | """ 10 | 11 | # --- Constants --- 12 | ICONS_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "icons") 13 | ICONS_MAPPING_PATH = os.path.join(ICONS_PATH, "IconMapping") 14 | ICONS_ZIP_PATH = os.path.join(ICONS_PATH, "icons.zip") 15 | 16 | ICON_MAPPINGS: Dict[str, str] = {} 17 | 18 | # Load icon mappings from file 19 | with open(ICONS_MAPPING_PATH, "r") as file: 20 | for line in file: 21 | if line.startswith("#") or ":=" not in line: 22 | continue 23 | key, value = line.split(":=") 24 | ICON_MAPPINGS[key.strip()] = ( 25 | value.strip().rstrip(";").replace("_", "/", 1) 26 | ) 27 | 28 | PATH_ROLE = Qt.UserRole + 1 29 | DATA_ROLE = Qt.UserRole + 2 30 | -------------------------------------------------------------------------------- /ui/custom_qtree_view.py: -------------------------------------------------------------------------------- 1 | import os 2 | from typing import List 3 | 4 | from hutil.Qt.QtWidgets import QTreeView, QMenu, QAction 5 | from hutil.Qt.QtCore import Qt, QModelIndex 6 | from hutil.Qt.QtGui import QMouseEvent, QPainter, QPixmap, QIcon, QColor 7 | from ui.constants import ICONS_PATH 8 | from ui.ui_utils import generate_link_to_clipboard 9 | 10 | 11 | class CustomQTreeView(QTreeView): 12 | """ 13 | A custom QTreeView that provides additional functionalities such as 14 | recursive expanding/collapsing of items and enhanced mouse click handling. 15 | """ 16 | 17 | def __init__(self, parent=None): 18 | super(CustomQTreeView, self).__init__(parent) 19 | self.parent_application = parent 20 | 21 | def mousePressEvent(self, event: QMouseEvent) -> None: 22 | """ 23 | Handle mouse press events to detect a Shift+Click 24 | and expand or collapse all children of the clicked node accordingly. 25 | 26 | :param event: The mouse event triggered by user's action. 27 | """ 28 | super().mousePressEvent(event) 29 | 30 | if event.modifiers() & Qt.ShiftModifier: 31 | self.expand_or_collapse_all(self.indexAt(event.pos())) 32 | 33 | def expand_or_collapse_all(self, index: QModelIndex) -> None: 34 | """ 35 | Toggle the expansion state for the specified index and its 36 | descendants. 37 | 38 | :param index: The QModelIndex of the item in the QTreeView. 39 | """ 40 | toggle_expansion = not self.isExpanded(index) 41 | self.recursive_expand_or_collapse(index, toggle_expansion) 42 | self.setExpanded(index, toggle_expansion) 43 | 44 | def recursive_expand_or_collapse( 45 | self, index: QModelIndex, expand: bool 46 | ) -> None: 47 | """ 48 | Recursively set the expansion state for the given index 49 | and its descendants. 50 | 51 | :param index: QModelIndex of the item in the QTreeView. 52 | :param expand: Boolean indicating desired state 53 | (True for expand, False for collapse). 54 | """ 55 | for child_row in range(self.model().rowCount(index)): 56 | child_index = index.child(child_row, 0) 57 | self.recursive_expand_or_collapse(child_index, expand) 58 | self.setExpanded(child_index, expand) 59 | 60 | def expand_to_index(self, item, treeview: QTreeView) -> None: 61 | """ 62 | Expand the QTreeView to reveal the specified item. 63 | 64 | :param item: The QStandardItem whose position in the tree 65 | you want to reveal. 66 | :param treeview: The QTreeView in which the item resides. 67 | """ 68 | index = treeview.model().indexFromItem(item) 69 | parent = index.parent() 70 | while parent.isValid(): 71 | treeview.expand(parent) 72 | parent = parent.parent() 73 | 74 | def get_child_indices(self, index: QModelIndex) -> List[QModelIndex]: 75 | """ 76 | Retrieve all child indices for the given index. 77 | 78 | :param index: QModelIndex of the item in the QTreeView. 79 | :return: List of QModelIndex instances representing each child. 80 | """ 81 | return [ 82 | index.child(row, 0) for row in range(self.model().rowCount(index)) 83 | ] 84 | 85 | def paintEvent(self, event) -> None: 86 | """ 87 | Handle painting the QTreeView, enabling anti-aliasing 88 | for smoother visuals. 89 | 90 | :param event: The paint event triggered by the Qt framework. 91 | """ 92 | painter = QPainter(self.viewport()) 93 | painter.setRenderHint(QPainter.Antialiasing, True) 94 | super().paintEvent(event) 95 | 96 | def contextMenuEvent(self, event): 97 | index = self.indexAt(event.pos()) 98 | if not index.isValid(): 99 | return 100 | 101 | item_path = index.data(index.model().path_role) 102 | 103 | copy_path_action = QAction("Copy node path", self) 104 | copy_path_action.triggered.connect( 105 | lambda checked=False, path=item_path: self._copy_path_to_clipboard(path) 106 | ) 107 | 108 | copy_link_action = QAction("Copy link", self) 109 | copy_link_action.triggered.connect( 110 | lambda checked=False, path=item_path: self._copy_link_to_clipboard(path) 111 | ) 112 | 113 | menu = QMenu(self) 114 | menu.setStyleSheet(""" 115 | QMenu { 116 | background-color: #505050; 117 | font: 10pt "DS Houdini"; 118 | color: #dfdfdf; 119 | border-radius: 5px; 120 | } 121 | QMenu::item { 122 | padding: 10px 20px 10px 40px; 123 | } 124 | QMenu::item:selected { 125 | background-color: #606060; 126 | } 127 | """) 128 | menu.addAction(copy_path_action) 129 | menu.addAction(copy_link_action) 130 | menu.exec_(event.globalPos()) 131 | 132 | 133 | def _copy_path_to_clipboard(self, item_path): 134 | print("ITEM_PATH_INSIDE_FUNC:", item_path) 135 | self.parent_application.clipboard.setText(item_path) 136 | 137 | def _copy_link_to_clipboard(self, item_path): 138 | generate_link_to_clipboard(self.parent_application, item_path) -------------------------------------------------------------------------------- /ui/custom_standart_item_model.py: -------------------------------------------------------------------------------- 1 | import copy 2 | import zipfile 3 | from typing import Optional 4 | 5 | from hutil.Qt.QtGui import ( 6 | QPixmap, 7 | QIcon, 8 | QStandardItemModel, 9 | QStandardItem, 10 | QColor, 11 | QBrush, 12 | ) 13 | from hutil.Qt.QtCore import Qt 14 | 15 | from api.data.item_data import ItemState 16 | from ui.constants import ICONS_ZIP_PATH, PATH_ROLE, DATA_ROLE, ICON_MAPPINGS 17 | 18 | 19 | class CustomStandardItemModel(QStandardItemModel): 20 | """ 21 | Custom implementation of QStandardItemModel with functionality 22 | for handling items with unique paths and icon management. 23 | """ 24 | 25 | def __init__(self, *args, **kwargs): 26 | super(CustomStandardItemModel, self).__init__(*args, **kwargs) 27 | 28 | self.item_dictionary = {} 29 | self.path_role = PATH_ROLE 30 | self.data_role = DATA_ROLE 31 | self.view = None 32 | self.show_only_edited = False 33 | self.proxy_model = None 34 | 35 | def set_view(self, tree_view) -> None: 36 | """Associate the model with a tree view widget.""" 37 | self.view = tree_view 38 | 39 | def _set_icon_from_zip( 40 | self, item: QStandardItem, icon_name: str, icons_zip: zipfile.ZipFile 41 | ) -> None: 42 | """Extract and set icon to item from given zip file.""" 43 | if not icon_name: 44 | return 45 | 46 | try: 47 | with icons_zip.open(icon_name) as file: 48 | icon_data = file.read() 49 | pixmap = QPixmap() 50 | pixmap.loadFromData(icon_data) 51 | item.setIcon(QIcon(pixmap)) 52 | except Exception: 53 | pass 54 | 55 | def add_item_with_path( 56 | self, 57 | item_text: str, 58 | path: str, 59 | data, 60 | icons_zip: zipfile.ZipFile, 61 | parent: Optional[QStandardItem] = None, 62 | ) -> None: 63 | """Add item with given attributes and associated path to the model.""" 64 | item = QStandardItem(item_text) 65 | item.setData(path, self.path_role) 66 | item.setData(data, self.data_role) 67 | item.setFlags(item.flags() & ~Qt.ItemIsEditable) 68 | 69 | icon_path = ICON_MAPPINGS.get(data.icon, data.icon) 70 | if icon_path: 71 | icon_path = icon_path.replace("_", "/", 1) + ".svg" 72 | self._set_icon_from_zip(item, icon_path, icons_zip) 73 | 74 | (parent.appendRow if parent else self.appendRow)(item) 75 | 76 | self.item_dictionary[path] = item 77 | if data.user_data: 78 | self._add_user_data_item(item, data.user_data) 79 | 80 | for parm_name in data.parms: 81 | self._add_parm_items(item, data, parm_name, icons_zip) 82 | 83 | def _add_user_data_item( 84 | self, 85 | item: QStandardItem, 86 | user_data, 87 | ) -> None: 88 | """Add parameters as child items to given item.""" 89 | parm_name = "userDataDict" 90 | parm = user_data 91 | 92 | if not parm.state: 93 | return 94 | 95 | updated_parm_name = parm_name if parm.is_active else "" 96 | 97 | path = item.data(self.path_role) 98 | parm_path = f"{path}/{parm_name}" 99 | parm_item = QStandardItem(updated_parm_name) 100 | parm_item.setData(parm, self.data_role) 101 | parm_item.setData(parm_path, self.path_role) 102 | parm_item.setFlags(parm_item.flags() & ~Qt.ItemIsEditable) 103 | 104 | item.appendRow(parm_item) 105 | self.item_dictionary[parm_path] = parm_item 106 | 107 | value = str(parm.value) if parm.is_active else "" 108 | 109 | value_path = f"{parm_path}/value" 110 | value_item = QStandardItem(value) 111 | value_item.setFlags(parm_item.flags() & ~Qt.ItemIsEditable) 112 | value_data = copy.copy(parm) 113 | value_data.state = ItemState.VALUE 114 | value_item.setData(value_data, self.data_role) 115 | value_item.setData(value_path, self.path_role) 116 | 117 | parm_item.appendRow(value_item) 118 | self.item_dictionary[value_path] = value_item 119 | 120 | 121 | def _add_parm_items( 122 | self, 123 | item: QStandardItem, 124 | data, 125 | parm_name: str, 126 | icons_zip: zipfile.ZipFile, 127 | ) -> None: 128 | """Add parameters as child items to given item.""" 129 | parm = data.get_parm_by_name(parm_name) 130 | 131 | if not parm.state: 132 | return 133 | 134 | updated_parm_name = parm_name if parm.is_active else "" 135 | 136 | path = item.data(self.path_role) 137 | parm_path = f"{path}/{parm_name}" 138 | parm_item = QStandardItem(updated_parm_name) 139 | parm_item.setData(parm, self.data_role) 140 | parm_item.setData(parm_path, self.path_role) 141 | parm_item.setFlags(parm_item.flags() & ~Qt.ItemIsEditable) 142 | 143 | if parm.is_active and parm.icon: 144 | self._set_icon_from_zip(parm_item, f"VOP/parameter.svg", icons_zip) 145 | 146 | item.appendRow(parm_item) 147 | self.item_dictionary[parm_path] = parm_item 148 | 149 | value = str(parm.value) if parm.is_active else "" 150 | value_path = f"{parm_path}/value" 151 | value_item = QStandardItem(value) 152 | value_item.setFlags(parm_item.flags() & ~Qt.ItemIsEditable) 153 | value_data = copy.copy(parm) 154 | value_data.state = ItemState.VALUE 155 | value_item.setData(value_data, self.data_role) 156 | value_item.setData(value_path, self.path_role) 157 | 158 | parm_item.appendRow(value_item) 159 | self.item_dictionary[value_path] = value_item 160 | 161 | def get_item_by_path(self, path: str) -> Optional[QStandardItem]: 162 | """Return the item associated with given path.""" 163 | return self.item_dictionary.get(path) 164 | 165 | def populate_with_data(self, data, view_name: str) -> None: 166 | """Populate the model with given data and associate with a view.""" 167 | with zipfile.ZipFile(ICONS_ZIP_PATH, "r") as zip_ref: 168 | for path in data: 169 | node_data = data[path] 170 | node_name = ( 171 | node_data.name if node_data.name != "/" else view_name 172 | ) 173 | parent_path = node_data.parent_path 174 | parent_item = self.get_item_by_path(parent_path) 175 | self.add_item_with_path( 176 | node_name, path, node_data, zip_ref, parent=parent_item 177 | ) 178 | 179 | self.paint_items_and_expand(self.invisibleRootItem(), view_name) 180 | 181 | def paint_items_and_expand(self, parent_item, view_name: str) -> None: 182 | """Recursively style and expand items starting from the parent item.""" 183 | for row in range(parent_item.rowCount()): 184 | for column in range(parent_item.columnCount()): 185 | child_item = parent_item.child(row, 0) 186 | if child_item: 187 | self._apply_item_style_and_expansion(child_item) 188 | self.paint_items_and_expand(child_item, view_name) 189 | 190 | def _apply_item_style_and_expansion(self, item: QStandardItem) -> None: 191 | """Style and expand the given item based on its properties.""" 192 | item_data = item.data(Qt.UserRole + 2) 193 | color = item_data.color 194 | if color: 195 | qcolor = QColor(color) 196 | qcolor.setAlpha(item_data.alpha) 197 | item.setBackground(QBrush(qcolor)) 198 | if item_data.state and item_data.state != ItemState.VALUE: 199 | self.view.expand_to_index(item, self.view) 200 | -------------------------------------------------------------------------------- /ui/file_selector.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from hutil.Qt.QtGui import QPixmap, QIcon 4 | from hutil.Qt.QtWidgets import ( 5 | QWidget, 6 | QHBoxLayout, 7 | QLineEdit, 8 | QPushButton, 9 | QFileDialog, 10 | ) 11 | from ui.constants import ICONS_PATH 12 | 13 | 14 | class FileSelector(QWidget): 15 | """ 16 | A custom QWidget for selecting and displaying a file path. 17 | This widget combines a QLineEdit and a QPushButton for file browsing. 18 | """ 19 | 20 | def __init__(self, parent: QWidget = None): 21 | """ 22 | Initialize the FileSelector widget. 23 | 24 | Args: 25 | parent (QWidget, optional): Parent widget for the FileSelector. 26 | Defaults to None. 27 | """ 28 | super().__init__(parent) 29 | 30 | self.layout = QHBoxLayout(self) 31 | self.layout.setContentsMargins(1, 1, 1, 1) 32 | 33 | self.setup_line_edit() 34 | self.setup_browse_button() 35 | self._set_styles() 36 | 37 | self.setContentsMargins(0, 0, 0, 0) 38 | 39 | def setup_line_edit(self): 40 | """Configure the QLineEdit component.""" 41 | self.lineEdit = QLineEdit(self) 42 | self.lineEdit.setFixedHeight(30) 43 | self.layout.addWidget(self.lineEdit) 44 | 45 | def setup_browse_button(self): 46 | """Configure the QPushButton component for file browsing.""" 47 | self.browseButton = QPushButton(self) 48 | pixmap = QPixmap(os.path.join(ICONS_PATH, "folder.png")) 49 | self.browseButton.setIcon(QIcon(pixmap)) 50 | self.browseButton.setFixedSize(30, 30) 51 | self.browseButton.clicked.connect(self.browse) 52 | self.layout.addWidget(self.browseButton) 53 | 54 | def browse(self): 55 | """Open a file dialog and set file path to the QLineEdit.""" 56 | fname, _ = QFileDialog.getOpenFileName(self, "Open file") 57 | if fname: 58 | self.lineEdit.setText(fname) 59 | 60 | def setText(self, text: str): 61 | """ 62 | Set the content of the QLineEdit. 63 | 64 | Args: 65 | text (str): The text to display in the QLineEdit. 66 | """ 67 | self.lineEdit.setText(text) 68 | 69 | def setPlaceholderText(self, text: str): 70 | """ 71 | Set placeholder text for the QLineEdit. 72 | 73 | Args: 74 | text (str): Placeholder text to be displayed 75 | when QLineEdit is empty. 76 | """ 77 | self.lineEdit.setPlaceholderText(text) 78 | 79 | def text(self) -> str: 80 | """Return the current text from the QLineEdit. 81 | 82 | Returns: 83 | str: Text currently displayed in the QLineEdit. 84 | """ 85 | return self.lineEdit.text() 86 | 87 | def _set_styles(self): 88 | """Private method to apply CSS styles for the widget components.""" 89 | # Styles for the browse button 90 | self.browseButton.setStyleSheet( 91 | """ 92 | QPushButton{ 93 | font: 8pt "Arial"; 94 | background-color: transparent; 95 | border-radius: 10px; 96 | } 97 | QPushButton:hover { 98 | color: #919191; 99 | background-color: #555555; 100 | border: 1px solid rgb(185, 134, 32); 101 | } 102 | """ 103 | ) 104 | 105 | # Styles for the line edit 106 | self.lineEdit.setStyleSheet( 107 | """ 108 | QLineEdit{ 109 | font: 10pt "Arial"; 110 | color: #818181; 111 | background-color: #464646; 112 | border-radius: 10px; 113 | padding-left: 5px; 114 | padding-right: 5px; 115 | } 116 | QLineEdit:hover, QLineEdit:selected { 117 | color: #919191; 118 | background-color: #555555; 119 | border: 1px solid rgb(185, 134, 32); 120 | } 121 | """ 122 | ) 123 | -------------------------------------------------------------------------------- /ui/hatched_pattern_item_delegate.py: -------------------------------------------------------------------------------- 1 | from hutil.Qt.QtGui import QPixmap, QColor, QBrush, QPen, QPainter, QLinearGradient 2 | from hutil.Qt.QtWidgets import QStyledItemDelegate, QStyle 3 | from hutil.Qt.QtCore import Qt, QSize, QEvent 4 | 5 | from ui.constants import DATA_ROLE 6 | 7 | 8 | class HatchedItemDelegate(QStyledItemDelegate): 9 | """ 10 | Custom item delegate class that supports hatched patterns as backgrounds. 11 | """ 12 | def paint(self, painter: QPainter, option, index) -> None: 13 | """ 14 | Custom paint method to render items with a hatched pattern. 15 | 16 | :param painter: QPainter instance used to draw the item. 17 | :param option: Provides style options for the item. 18 | :param index: QModelIndex of the item in the model. 19 | """ 20 | is_hatched = index.data(DATA_ROLE).is_hatched 21 | if is_hatched: 22 | self._paint_hatched_pattern(painter, option) 23 | 24 | option.displayAlignment = Qt.AlignTop 25 | 26 | if index.data(Qt.DisplayRole).count("\n") >= 3: 27 | # Create the gradient overlay effect for darkening 28 | gradient = QLinearGradient( 29 | option.rect.topLeft(), 30 | option.rect.bottomLeft() 31 | ) 32 | gradient.setColorAt(0.25, Qt.transparent) 33 | gradient.setColorAt(1, QColor("#202020")) 34 | 35 | painter.fillRect(option.rect, gradient) 36 | 37 | borderColor = QColor(Qt.transparent) 38 | is_hovered = option.state & QStyle.State_MouseOver 39 | if is_hovered: 40 | borderColor = QColor(185, 134, 32) 41 | 42 | painter.setPen(borderColor) 43 | painter.drawLine(option.rect.topLeft(), option.rect.topRight()) 44 | painter.drawLine(option.rect.bottomLeft(), option.rect.bottomRight()) 45 | painter.drawLine(option.rect.topLeft(), option.rect.bottomLeft()) 46 | painter.drawLine(option.rect.topRight(), option.rect.bottomRight()) 47 | 48 | super().paint(painter, option, index) 49 | 50 | def sizeHint(self, option, index): 51 | text = index.data(Qt.DisplayRole) 52 | if "\n" in text: 53 | return QSize(option.rect.width(), 100) 54 | 55 | return super().sizeHint(option, index) 56 | 57 | def helpEvent(self, event, view, option, index): 58 | if event.type() == QEvent.ToolTip and index.data(Qt.DisplayRole): 59 | if index.data(Qt.DisplayRole).count("\n") >= 3 : 60 | view.setToolTip( 61 | "String diff available for this item," 62 | "double click on item to open.") 63 | else: 64 | view.setToolTip("") # Clear the tooltip for other items 65 | return super(HatchedItemDelegate, self)\ 66 | .helpEvent(event, view, option, index) 67 | 68 | 69 | def _paint_hatched_pattern(self, painter: QPainter, option) -> None: 70 | """ 71 | Draws the hatched pattern on the item. 72 | 73 | :param painter: QPainter instance used to draw the item. 74 | :param option: Provides style options for the item. 75 | """ 76 | hatch_width = 1000 77 | pixmap = QPixmap(hatch_width, hatch_width) 78 | pixmap.fill(Qt.transparent) 79 | 80 | pen_color = QColor("#505050") 81 | pen_width = 3 82 | pen = QPen(pen_color, pen_width) 83 | pen.setCapStyle(Qt.FlatCap) 84 | 85 | pixmap_painter = QPainter(pixmap) 86 | pixmap_painter.setPen(pen) 87 | for i in range(-hatch_width, hatch_width, pen_width * 6): 88 | pixmap_painter.drawLine(i, hatch_width, hatch_width + i, 0) 89 | pixmap_painter.end() 90 | 91 | brush = QBrush(pixmap) 92 | painter.fillRect(option.rect, brush) 93 | 94 | # Clear the background brush to avoid default painting 95 | option.backgroundBrush = QBrush(Qt.transparent) 96 | -------------------------------------------------------------------------------- /ui/hatched_text_edit.py: -------------------------------------------------------------------------------- 1 | from hutil.Qt.QtWidgets import QTextEdit 2 | from hutil.Qt.QtGui import ( 3 | QPixmap, 4 | QPen, 5 | QPainter, 6 | QColor, 7 | QBrush 8 | ) 9 | from hutil.Qt.QtWidgets import QTextEdit 10 | from hutil.Qt.QtCore import Qt, QRect 11 | 12 | class HatchedTextEdit(QTextEdit): 13 | def __init__(self, *args, **kwargs): 14 | """ 15 | A custom QTextEdit that supports displaying hatched patterns on specific lines. 16 | 17 | Args: 18 | *args: Variable length argument list. 19 | **kwargs: Arbitrary keyword arguments. 20 | """ 21 | super(HatchedTextEdit, self).__init__(*args, **kwargs) 22 | self.hatched_lines = set() 23 | self.text_lines = "" 24 | 25 | def clearHatchedPatternForLine(self, line_number) -> None: 26 | """ 27 | Clears the hatched pattern for a specified line number. 28 | 29 | Args: 30 | line_number (int): The line number for which the hatched pattern should be cleared. 31 | """ 32 | if line_number in self.hatched_lines: 33 | self.hatched_lines.remove(line_number) 34 | self.viewport().update() 35 | 36 | def paintEvent(self, event) -> None: 37 | """ 38 | Handles the paint event to draw hatched patterns on specified lines. 39 | 40 | Args: 41 | event: The paint event. 42 | """ 43 | # Let the QTextEdit handle its regular painting 44 | super(HatchedTextEdit, self).paintEvent(event) 45 | 46 | for index, line in enumerate(self.text_lines): 47 | if "data_hashed_line" in line: 48 | self.hatched_lines.add(index) 49 | if self.hatched_lines: 50 | painter = QPainter(self.viewport()) 51 | for line_number in self.hatched_lines: 52 | block = self.document().findBlockByLineNumber(line_number) 53 | layout = block.layout() 54 | if layout is not None: 55 | position = layout.position() 56 | rect = QRect(0, position.y(), self.viewport().width(), layout.boundingRect().height()) 57 | self._paint_hatched_pattern(painter, rect) 58 | painter.end() 59 | 60 | def _paint_hatched_pattern(self, painter: QPainter, rect: QRect) -> None: 61 | """ 62 | Paints a hatched pattern within a given rectangle. 63 | 64 | Args: 65 | painter (QPainter): The painter used for drawing. 66 | rect (QRect): The rectangle area where the pattern should be painted. 67 | """ 68 | hatch_width = 1000 69 | pixmap = QPixmap(hatch_width, hatch_width) 70 | pixmap.fill(Qt.transparent) 71 | 72 | pen_color = QColor("#505050") 73 | pen_width = 3 74 | pen = QPen(pen_color, pen_width) 75 | pen.setCapStyle(Qt.FlatCap) 76 | 77 | pixmap_painter = QPainter(pixmap) 78 | pixmap_painter.setPen(pen) 79 | for i in range(-hatch_width, hatch_width, pen_width * 6): 80 | pixmap_painter.drawLine(i, hatch_width, hatch_width + i, 0) 81 | pixmap_painter.end() 82 | 83 | brush = QBrush(pixmap) 84 | painter.fillRect(rect, brush) -------------------------------------------------------------------------------- /ui/hip_file_diff_window.py: -------------------------------------------------------------------------------- 1 | import os 2 | from pathlib import Path 3 | 4 | from hutil.Qt.QtWidgets import ( 5 | QApplication, 6 | QMainWindow, 7 | QWidget, 8 | QVBoxLayout, 9 | QHBoxLayout, 10 | QPushButton, 11 | QSplitter, 12 | QMessageBox, 13 | QAbstractItemView, 14 | QCheckBox 15 | ) 16 | from hutil.Qt.QtCore import ( 17 | Qt, 18 | QSortFilterProxyModel, 19 | QEvent, 20 | QItemSelectionModel 21 | ) 22 | from hutil.Qt.QtGui import QHoverEvent 23 | 24 | from api.comparators.houdini_base_comparator import HoudiniComparator, HIP_FILE_FORMATS 25 | from api.comparators.hip_comparator import HipFileComparator 26 | 27 | from ui.custom_qtree_view import CustomQTreeView 28 | from ui.custom_standart_item_model import CustomStandardItemModel 29 | from ui.hatched_pattern_item_delegate import HatchedItemDelegate 30 | from ui.file_selector import FileSelector 31 | from ui.search_line_edit import QTreeViewSearch 32 | from ui.string_diff_dialog import StringDiffDialog 33 | 34 | 35 | class HipFileDiffWindow(QMainWindow): 36 | """ 37 | Main window for displaying the differences between two .hip files. 38 | 39 | Attributes: 40 | hip_comparator (HipFileComparator): Instance to compare two hip files. 41 | """ 42 | 43 | def __init__(self, args): 44 | super(HipFileDiffWindow, self).__init__() 45 | self.houdini_comparator: HoudiniComparator = None 46 | self.args = args 47 | self.init_ui(args) 48 | 49 | def init_ui(self, args) -> None: 50 | """Initialize UI components.""" 51 | self.set_window_properties() 52 | self.setup_layouts() 53 | self.setup_tree_views() 54 | self.setup_checkboxes() 55 | self.setup_signals_and_slots() 56 | self.apply_stylesheet() 57 | 58 | self.clipboard = QApplication.clipboard() 59 | self.main_path = args.main_path 60 | 61 | if args.source_file_path: 62 | self.source_file_line_edit.setText(args.source_file_path) 63 | 64 | if args.target_file_path: 65 | self.target_file_line_edit.setText(args.target_file_path) 66 | 67 | if args.source_file_path and args.target_file_path: 68 | self.handle_compare_button_click() 69 | 70 | if args.item_path: 71 | item = self.source_model.get_item_by_path(args.item_path) 72 | if not item: 73 | QMessageBox.critical( 74 | None, "Error", "Specified item on this path was not found!" 75 | ) 76 | else: 77 | self.source_treeview.expand_to_index(item, self.source_treeview) 78 | self.on_item_double_clicked(item.index()) 79 | 80 | self.show_only_edited_checkbox.setChecked(True) 81 | 82 | 83 | def set_window_properties(self) -> None: 84 | """Set main window properties.""" 85 | self.setWindowTitle(".hip files diff tool") 86 | self.setGeometry(300, 300, 2000, 1300) 87 | self.main_widget = QWidget(self) 88 | self.setCentralWidget(self.main_widget) 89 | 90 | def setup_layouts(self) -> None: 91 | """Setup main, source and target layouts for the main window.""" 92 | self.main_layout = QVBoxLayout(self.main_widget) 93 | self.main_layout.setContentsMargins(3, 5, 3, 3) 94 | self.setup_source_layout() 95 | self.setup_target_layout() 96 | 97 | splitter = QSplitter(Qt.Horizontal) 98 | splitter.addWidget(self.source_widget) 99 | splitter.addWidget(self.target_widget) 100 | splitter.setSizes([self.width() // 2, self.width() // 2]) 101 | self.main_layout.addWidget(splitter) 102 | 103 | def setup_source_layout(self) -> None: 104 | """Setup layout for the source file section.""" 105 | self.source_file_line_edit = FileSelector(self) 106 | self.source_file_line_edit.setPlaceholderText("Source file path") 107 | 108 | self.source_widget = QWidget() 109 | self.source_layout = QVBoxLayout(self.source_widget) 110 | 111 | self.source_layout.addWidget(self.source_file_line_edit) 112 | self.source_layout.setContentsMargins(3, 3, 3, 3) 113 | 114 | def setup_target_layout(self) -> None: 115 | """Setup layout for the target file section.""" 116 | self.target_file_line_edit = FileSelector(self) 117 | self.target_file_line_edit.setObjectName("FileSelector") 118 | self.target_file_line_edit.setPlaceholderText("Target file path") 119 | 120 | self.load_button = QPushButton("Compare", self) 121 | self.load_button.setObjectName("compareButton") 122 | self.load_button.setFixedHeight(30) 123 | self.load_button.setFixedWidth(100) 124 | 125 | self.target_top_hlayout = QHBoxLayout() 126 | self.target_top_hlayout.addWidget(self.target_file_line_edit) 127 | self.target_top_hlayout.addWidget(self.load_button) 128 | 129 | self.target_widget = QWidget() 130 | self.target_layout = QVBoxLayout(self.target_widget) 131 | self.target_layout.addLayout(self.target_top_hlayout) 132 | self.target_layout.setContentsMargins(3, 3, 3, 3) 133 | 134 | def setup_tree_views(self) -> None: 135 | """ 136 | Setup QTreeViews and associate models for source and target sections. 137 | """ 138 | self.source_treeview = self.create_tree_view("source") 139 | self.source_model = CustomStandardItemModel() 140 | self.source_model.set_view(self.source_treeview) 141 | self.source_treeview.setModel(self.source_model) 142 | self.source_layout.addWidget(self.source_treeview) 143 | 144 | self.target_treeview = self.create_tree_view( 145 | "target", hide_scrollbar=False 146 | ) 147 | self.target_model = CustomStandardItemModel() 148 | self.target_model.set_view(self.target_treeview) 149 | self.target_treeview.setModel(self.target_model) 150 | self.target_layout.addWidget(self.target_treeview) 151 | 152 | self.source_search_qline_edit = QTreeViewSearch( 153 | self.source_treeview, self.source_model 154 | ) 155 | self.source_search_qline_edit.setPlaceholderText("Search in source") 156 | self.source_layout.addWidget(self.source_search_qline_edit) 157 | 158 | self.target_search_qline_edit = QTreeViewSearch( 159 | self.target_treeview, self.target_model 160 | ) 161 | self.target_search_qline_edit.setPlaceholderText("Search in target") 162 | self.target_layout.addWidget(self.target_search_qline_edit) 163 | 164 | self.source_search_qline_edit.second_search = ( 165 | self.target_search_qline_edit 166 | ) 167 | self.target_search_qline_edit.second_search = ( 168 | self.source_search_qline_edit 169 | ) 170 | 171 | self.target_search_qline_edit.secondary_treeview = self.source_treeview 172 | self.target_search_qline_edit.secondary_proxy_model = ( 173 | self.source_treeview.model() 174 | ) 175 | self.target_model.rowsInserted.connect( 176 | self.target_search_qline_edit.proxy_model.invalidate 177 | ) 178 | self.target_model.rowsRemoved.connect( 179 | self.target_search_qline_edit.proxy_model.invalidate 180 | ) 181 | 182 | self.source_search_qline_edit.secondary_treeview = self.target_treeview 183 | self.source_search_qline_edit.secondary_proxy_model = ( 184 | self.target_treeview.model() 185 | ) 186 | self.source_model.rowsInserted.connect( 187 | self.source_search_qline_edit.proxy_model.invalidate 188 | ) 189 | self.source_model.rowsRemoved.connect( 190 | self.source_search_qline_edit.proxy_model.invalidate 191 | ) 192 | 193 | def create_tree_view( 194 | self, obj_name: str, hide_scrollbar: bool = True 195 | ) -> CustomQTreeView: 196 | """ 197 | Create a QTreeView with specified properties. 198 | 199 | Args: 200 | - obj_name (str): Object name for the QTreeView. 201 | - hide_scrollbar (bool): If True, hide the scrollbar. Default is True. 202 | 203 | Returns: 204 | - CustomQTreeView: Configured QTreeView instance. 205 | """ 206 | tree_view = CustomQTreeView(self) 207 | tree_view.setItemDelegate(HatchedItemDelegate(tree_view)) 208 | tree_view.doubleClicked.connect(self.on_item_double_clicked) 209 | 210 | tree_view.setObjectName(obj_name) 211 | tree_view.header().hide() 212 | tree_view.setHorizontalScrollMode(QAbstractItemView.ScrollPerPixel) 213 | tree_view.setVerticalScrollMode(QAbstractItemView.ScrollPerPixel) 214 | 215 | if hide_scrollbar: 216 | tree_view.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) 217 | 218 | return tree_view 219 | 220 | def setup_signals_and_slots(self) -> None: 221 | """Connect signals to their respective slots.""" 222 | self.load_button.clicked.connect(self.handle_compare_button_click) 223 | 224 | self.connect_tree_view_expansion(self.source_treeview) 225 | self.connect_tree_view_expansion(self.target_treeview) 226 | 227 | self.connect_tree_view_hover(self.source_treeview) 228 | self.connect_tree_view_hover(self.target_treeview) 229 | 230 | self.target_treeview.verticalScrollBar().valueChanged.connect( 231 | self.sync_scroll 232 | ) 233 | self.source_treeview.verticalScrollBar().valueChanged.connect( 234 | self.sync_scroll 235 | ) 236 | 237 | def connect_tree_view_hover(self, tree_view: CustomQTreeView) -> None: 238 | """ 239 | Connect hover signals for a QTreeView. 240 | 241 | Args: 242 | - tree_view (CustomQTreeView): The QTreeView instance. 243 | """ 244 | 245 | tree_view.setMouseTracking(True) 246 | tree_view.entered.connect( 247 | lambda index: self.sync_hover_state(index, hover=True) 248 | ) 249 | 250 | def connect_tree_view_expansion(self, tree_view: CustomQTreeView) -> None: 251 | """ 252 | Connect expansion signals for a QTreeView. 253 | 254 | Args: 255 | - tree_view (CustomQTreeView): The QTreeView instance. 256 | """ 257 | tree_view.expanded.connect( 258 | lambda index: self.sync_expand(index, expand=True) 259 | ) 260 | tree_view.collapsed.connect( 261 | lambda index: self.sync_expand(index, expand=False) 262 | ) 263 | 264 | def sync_hover_state(self, index, hover: bool = True) -> None: 265 | """ 266 | Synchronize hover/unhover state between tree views. 267 | 268 | Args: 269 | - index: QModelIndex of the item being hovered/unhovered. 270 | - hover (bool): If True, item is hovered. If False, it's unhovered. 271 | """ 272 | event_proxy_model = index.model() 273 | if isinstance(event_proxy_model, QSortFilterProxyModel): 274 | event_source_model = event_proxy_model.sourceModel() 275 | else: 276 | event_source_model = event_proxy_model 277 | 278 | if event_source_model == self.source_model: 279 | other_view = self.target_treeview 280 | else: 281 | other_view = self.source_treeview 282 | 283 | event_item = event_source_model.itemFromIndex( 284 | event_proxy_model.mapToSource(index) 285 | ) 286 | event_item_path = event_item.data(event_source_model.path_role) 287 | 288 | item_in_other_source_model = ( 289 | other_view.model().sourceModel().get_item_by_path(event_item_path) 290 | ) 291 | 292 | index_in_other_proxy = other_view.model().mapFromSource( 293 | other_view.model().sourceModel().indexFromItem(item_in_other_source_model) 294 | ) 295 | 296 | # Create a QHoverEvent and post it to the other tree view 297 | pos_in_other_view = other_view.visualRect(index_in_other_proxy).center() 298 | global_pos = other_view.mapToGlobal(pos_in_other_view) 299 | 300 | if hover: 301 | hover_event_type = QEvent.HoverEnter 302 | else: 303 | hover_event_type = QEvent.HoverLeave 304 | 305 | hover_event = QHoverEvent(hover_event_type, pos_in_other_view, global_pos) 306 | QApplication.postEvent(other_view.viewport(), hover_event) 307 | 308 | def apply_stylesheet(self) -> None: 309 | """Apply a custom stylesheet to the main window.""" 310 | base_path = os.path.dirname(os.path.abspath(__file__)) 311 | icons_path = os.path.join(base_path, 'icons').replace(os.sep, '/') 312 | 313 | stylesheet = f""" 314 | QMainWindow{{ 315 | background-color: #3c3c3c; 316 | }} 317 | QPushButton#compareButton {{ 318 | font: 10pt "Arial"; 319 | color: #818181; 320 | background-color: #464646; 321 | border-radius: 10px; 322 | }} 323 | QPushButton#compareButton:hover {{ 324 | color: #919191; 325 | background-color: #555555; 326 | border: 1px solid rgb(185, 134, 32); 327 | }} 328 | CustomQTreeView {{ 329 | font: 10pt "DS Houdini"; 330 | color: #dfdfdf; 331 | background-color: #333333; 332 | border-radius: 10px; 333 | }} 334 | QTreeView::branch:has-siblings:!adjoins-item {{ 335 | border-image: url("{icons_path}/vline.svg") center center no-repeat; 336 | }} 337 | QTreeView::branch:has-siblings:adjoins-item {{ 338 | border-image: url("{icons_path}/more.svg") center center no-repeat; 339 | }} 340 | QTreeView::branch:!has-children:!has-siblings:adjoins-item {{ 341 | border-image: url("{icons_path}/end.svg") center center no-repeat; 342 | }} 343 | QTreeView::branch:has-children:!has-siblings:closed, 344 | QTreeView::branch:closed:has-children:has-siblings {{ 345 | border-image: url({icons_path}/closed.svg) center center no-repeat; 346 | }} 347 | QTreeView::branch:open:has-children:!has-siblings, 348 | QTreeView::branch:open:has-children:has-siblings {{ 349 | border-image: url("{icons_path}/opened.svg") center center no-repeat; 350 | }} 351 | QTreeView::branch:!adjoins-item{{ 352 | border-image: url("{icons_path}/empty.svg") center center no-repeat; 353 | }} 354 | QTreeView::item {{ 355 | height: 1.1em; 356 | font-size: 0.4em; 357 | padding: 0.12em; 358 | }} 359 | QScrollBar:vertical {{ 360 | border: none; 361 | background: #333333; 362 | width: 20px; 363 | border: 1px solid #3c3c3c; 364 | }} 365 | QScrollBar::handle:vertical {{ 366 | background: #464646; 367 | min-width: 20px; 368 | }} 369 | QScrollBar::sub-line:vertical, QScrollBar::add-line:vertical {{ 370 | border: none; 371 | background: none; 372 | height: 0; 373 | subcontrol-position: top; 374 | subcontrol-origin: margin; 375 | }} 376 | QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical {{ 377 | background: none; 378 | }} 379 | 380 | QSplitter::handle {{ 381 | background-color: #3c3c3c; 382 | }} 383 | QSplitter::handle:vertical {{ 384 | height: 5px; 385 | }} 386 | QCheckBox {{ 387 | color: #818181; 388 | border-radius: 4px; 389 | }} 390 | QCheckBox::indicator:unchecked {{ 391 | background-color: #3c3c3c; 392 | border: 1px solid #818181; 393 | border-radius: 4px; 394 | }} 395 | QCheckBox::indicator:checked {{ 396 | background-color: #555555; 397 | border: 1px solid rgb(185, 134, 32); 398 | border-radius: 4px; 399 | }} 400 | QCheckBox::indicator:hover {{ 401 | border: 1px solid rgb(185, 134, 32); 402 | }} 403 | """ 404 | self.setStyleSheet( 405 | str(stylesheet) 406 | ) 407 | 408 | def setup_checkboxes(self): 409 | self.show_only_edited_checkbox = QCheckBox("Show only edited nodes") 410 | self.show_only_edited_checkbox.stateChanged.connect( 411 | self.on_checkbox_toggled 412 | ) 413 | 414 | self.checkbox_h_layout = QHBoxLayout() 415 | self.checkbox_h_layout.addWidget(self.show_only_edited_checkbox) 416 | self.checkbox_h_layout.setContentsMargins(10, 0, 0, 0) 417 | self.main_layout.addLayout(self.checkbox_h_layout) 418 | 419 | def handle_compare_button_click(self) -> None: 420 | """ 421 | Handle the logic when the "Compare" button is clicked. 422 | """ 423 | source_path = self.source_file_line_edit.text() 424 | target_path = self.target_file_line_edit.text() 425 | 426 | if not (os.path.exists(source_path) and os.path.exists(target_path)): 427 | QMessageBox.warning( 428 | self, 429 | "Invalid Paths", 430 | "Please select valid .hip files to compare.", 431 | ) 432 | return 433 | 434 | self.source_model.clear() 435 | self.source_treeview.item_dictionary = {} 436 | self.source_treeview.model().invalidateFilter() 437 | 438 | self.target_model.clear() 439 | self.target_treeview.item_dictionary = {} 440 | self.target_treeview.model().invalidateFilter() 441 | 442 | if Path(source_path).suffix[1:] not in HIP_FILE_FORMATS: 443 | QMessageBox.warning( 444 | self, 445 | "Unsupported file", 446 | f"Please select valid .hip files to compare. Supported extensions: {', '.join(HIP_FILE_FORMATS)}", 447 | ) 448 | return 449 | 450 | self.houdini_comparator = HipFileComparator(source_path, target_path) 451 | self.houdini_comparator.compare() 452 | 453 | # Assuming 'comparison_result' contains the differences, 454 | # we can now update our tree views based on the results. 455 | self.source_model.populate_with_data( 456 | self.houdini_comparator.source_data, self.source_treeview.objectName() 457 | ) 458 | self.target_model.populate_with_data( 459 | self.houdini_comparator.target_data, self.target_treeview.objectName() 460 | ) 461 | 462 | self.source_treeview.model().invalidateFilter() 463 | self.target_treeview.model().invalidateFilter() 464 | 465 | def on_checkbox_toggled(self, state) -> None: 466 | """ 467 | Handle the logic when "Show only edited nodes" checkbox is toggled. 468 | 469 | Args: 470 | - state: Current state of the checkbox. 471 | """ 472 | if state == Qt.Checked: 473 | self.source_model.show_only_edited = True 474 | self.target_model.show_only_edited = True 475 | 476 | self.source_search_qline_edit.capture_tree_state() 477 | self.target_search_qline_edit.capture_tree_state() 478 | 479 | self.source_treeview.model().reset_proxy_view() 480 | self.target_treeview.model().reset_proxy_view() 481 | else: 482 | self.source_model.show_only_edited = False 483 | self.target_model.show_only_edited = False 484 | 485 | self.source_treeview.model().reset_proxy_view() 486 | self.target_treeview.model().reset_proxy_view() 487 | 488 | if self.source_search_qline_edit.expanded_state: 489 | self.source_search_qline_edit.restore_tree_state() 490 | 491 | if self.target_search_qline_edit.expanded_state: 492 | self.target_search_qline_edit.restore_tree_state() 493 | 494 | def sync_expand(self, index, expand: bool = True) -> None: 495 | """ 496 | Synchronize expansion state between tree views. 497 | 498 | Args: 499 | - index: QModelIndex of the item being expanded or collapsed. 500 | - expand (bool): If True, item is expanded. If False, it's collapsed. 501 | """ 502 | other_view, index_in_other_proxy = self.get_index_in_other_model(index) 503 | other_view.setExpanded(index_in_other_proxy, expand) 504 | 505 | def get_index_in_other_model(self, index): 506 | event_proxy_model = index.model() 507 | if isinstance(event_proxy_model, QSortFilterProxyModel): 508 | event_source_model = event_proxy_model.sourceModel() 509 | else: 510 | event_source_model = event_proxy_model 511 | event_proxy_model = event_proxy_model.proxy_model 512 | index = event_proxy_model.mapFromSource(index) 513 | 514 | if event_source_model == self.source_model: 515 | other_view = self.target_treeview 516 | else: 517 | other_view = self.source_treeview 518 | 519 | event_item = event_source_model.itemFromIndex( 520 | event_proxy_model.mapToSource(index) 521 | ) 522 | event_item_path = event_item.data(event_source_model.path_role) 523 | 524 | item_in_other_source_model = ( 525 | other_view.model().sourceModel().get_item_by_path(event_item_path) 526 | ) 527 | 528 | index_in_other_proxy = other_view.model().mapFromSource( 529 | other_view.model() 530 | .sourceModel() 531 | .indexFromItem(item_in_other_source_model) 532 | ) 533 | return other_view, index_in_other_proxy 534 | 535 | def sync_scroll(self, value: int) -> None: 536 | """ 537 | Synchronize vertical scrolling between tree views. 538 | 539 | Args: 540 | - value (int): Vertical scroll position. 541 | """ 542 | # Fetch the source of the signal 543 | source_scrollbar = self.sender() 544 | 545 | # Determine the target scrollbar for synchronization 546 | if source_scrollbar == self.source_treeview.verticalScrollBar(): 547 | target_scrollbar = self.target_treeview.verticalScrollBar() 548 | else: 549 | target_scrollbar = self.source_treeview.verticalScrollBar() 550 | 551 | # Update the target's scrollbar position to match the source's 552 | target_scrollbar.setValue(value) 553 | 554 | def on_item_double_clicked(self, index): 555 | selection_model = self.source_treeview.selectionModel() 556 | selection_model.select( 557 | index, 558 | QItemSelectionModel.Select | QItemSelectionModel.Rows 559 | ) 560 | 561 | index_displ_role_text = index.data(Qt.DisplayRole) 562 | if index_displ_role_text.count("\n") >= 3 : 563 | _, index_in_other_proxy = self.get_index_in_other_model(index) 564 | string_diff_dialog = StringDiffDialog( 565 | index, 566 | index_in_other_proxy, 567 | parent=self 568 | ) 569 | self.installEventFilter(string_diff_dialog) 570 | string_diff_dialog.show() 571 | -------------------------------------------------------------------------------- /ui/icons/IconMapping: -------------------------------------------------------------------------------- 1 | # This file sets up a mapping between icon names and the names of the icon that 2 | # will be used instead. 3 | # These substitutions are performed unconditionally, so be careful when adding 4 | # new icons. 5 | 6 | DESKTOP_shortcut := DESKTOP_blank; 7 | 8 | BUTTONS_hq_browser := BUTTONS_internet; 9 | BUTTONS_sg_browser := BUTTONS_internet; 10 | BUTTONS_tools := BUTTONS_node_palette; 11 | BUTTONS_filter_processes := BUTTONS_filter_settings; 12 | 13 | SOP_null := COMMON_null; 14 | SOP_delete := COMMON_delete; 15 | SOP_file := COMMON_file; 16 | SOP_subnet := COMMON_subnet; 17 | SOP_switch := COMMON_switch; 18 | 19 | SOP_channel := NETWORKS_chop; 20 | SOP_constraintnetwork := NETWORKS_chop; 21 | SOP_muscle := OBJ_muscle; 22 | SOP_popnet := NETWORKS_pop; 23 | 24 | SOP_alembicxform := SOP_alembic; 25 | SOP_alembicarchive := SOP_alembic; 26 | SOP_alembicgroup := SOP_alembic; 27 | SOP_alembicprimitive := SOP_alembic; 28 | SOP_bakeode := MISC_generic; 29 | SOP_bakevex := MISC_generic; 30 | SOP_cache := MISC_generic; 31 | SOP_clothmatchpanels := MISC_generic; 32 | SOP_clothmatchseams := MISC_generic; 33 | SOP_collisionsource := SHELF_deforming_object; 34 | SOP_control := MISC_generic; 35 | SOP_convexdecomposition := SHELF_rbdconvexproxy; 36 | SOP_dopio := SOP_dopimport; 37 | SOP_extracttransform := OBJ_extractgeo; 38 | SOP_flipsource := SHELF_fluid_source; 39 | SOP_flipfabsink := SHELF_fluid_sink; 40 | SOP_grainsource := SHELF_convert_to_particlegrain; 41 | SOP_matchtopology := MISC_generic; 42 | SOP_sort := MISC_generic; 43 | SOP_starburst := MISC_generic; 44 | SOP_superquad := MISC_generic; 45 | SOP_wiretransfershape := MISC_generic; 46 | SOP_vopsop := NETWORKS_vop_sop; 47 | SOP_vex := NETWORKS_vop; 48 | SOP_knife := SHELF_knife; 49 | SOP_gluecluster := PARTS_glue; 50 | SOP_particlefluidtank := SHELF_flip_tank; 51 | SOP_lopimport := OBJ_lopimport; 52 | SOP_oceanevaluate := SHELF_wave; 53 | SOP_oceanspectrum := SHELF_wave; 54 | SOP_oceansource := SHELF_oceanwaves; 55 | SOP_oceanwaves := SHELF_oceanwaves; 56 | SOP_opencl := COMMON_opencl; 57 | SOP_pointvelocity := POP_velocity; 58 | SOP_python := MISC_python; 59 | SOP_pyrosolver := DOP_pyrosolver; 60 | SOP_pyrosource := SHELF_convert_to_fire; 61 | SOP_rbdbulletsolver := DOP_bulletsolver; 62 | SOP_rbdconfigure := DOP_rbdconfigureobject; 63 | SOP_kinefx-rop_gltfcharacteroutput := ROP_kinefx-gltfcharacter; 64 | SOP_shapediff := DATATYPES_direction_vector; 65 | SOP_split := DOP_split; 66 | SOP_sprite := POP_sprite; 67 | SOP_testsim_crowdtransition := CROWDS_transition; 68 | SOP_testsim_ragdoll := DOP_rbdragdollobject; 69 | SOP_timewarp := COP2_warp; 70 | SOP_output := VOP_output; 71 | SOP_bend := SHELF_twist_twist; 72 | SOP_copyxform := SOP_duplicate; 73 | SOP_fasciasolidify := MISC_fascia; 74 | SOP_neighborsearchcl := VOP_neighbour; 75 | 76 | SOP_capturepaint := SOP_capturelayerpaint; 77 | SOP_guideprocess := SOP_comb; 78 | SOP_hairclump := FUR_paint_clumping; 79 | SOP_guidegroom := FUR_shears; 80 | SOP_strokecache := FUR_recache_strokes; 81 | SOP_guideinit := FUR_initialize_guides; 82 | SOP_guidepartition := FUR_addparting; 83 | SOP_guidedeform := FUR_animate; 84 | SOP_guidetransfer := FUR_transfer; 85 | SOP_hairgen := FUR_hairgen; 86 | SOP_guidedraw := SOP_drawhair; 87 | SOP_haircardgen := FUR_haircardgen; 88 | 89 | SOP_vellumsolver := DOP_vellumsolver; 90 | SOP_vellumconstraints := DOP_vellumconstraints; 91 | SOP_vellumconstraintproperty := DOP_vellumconstraintproperty; 92 | SOP_vellumdrape := SHELF_vellum_cloth; 93 | SOP_vellumconstraints_grain := SHELF_vellum_grains; 94 | 95 | SOP_kinefx-adapttoterrain := CROWDS_agentterrainadaptation; 96 | SOP_kinefx-extractlocomotion := CHOP_extractlocomotion; 97 | SOP_kinefx-mocapimport := SHELF_motion_capture; 98 | SOP_kinefx-motionclipextractlocomotion := CHOP_extractlocomotion; 99 | 100 | SOP_tree_controller := SHELF_tree_deciduous; 101 | SOP_tree_trunk_generator := SHELF_tree_trunk; 102 | SOP_tree_branch_placer := SHELF_tree_branch; 103 | SOP_tree_branch_generator := SHELF_tree_branch_generator; 104 | SOP_tree_simple_leaf := SHELF_tree_leaf; 105 | SOP_tree_leaf_generator := SHELF_tree_leaf_generator; 106 | 107 | SOP_tetfracture := SOP_solidfracture; 108 | 109 | DOP_femsolidobject := DOP_solidobject; 110 | DOP_femsolidconfigureobject := DOP_solidobject; 111 | DOP_femhybridobject := DOP_hybridobject; 112 | DOP_femhybridconfigureobject := DOP_hybridobject; 113 | 114 | SOP_OpenVDB := COMMON_openvdb; 115 | SOP_gltf := COMMON_gltf; 116 | ROP_gltf := COMMON_gltf; 117 | OBJ_gltf_hierarchy := COMMON_gltf; 118 | 119 | SOP_fluidsource := SHELF_fluid_source; 120 | DOP_sourcevolume := SOP_volume; 121 | SOP_whitewatersource := SHELF_whitewater; 122 | SOP_pyrosourcespread := SHELF_spyro_firespread; 123 | DOP_smokeobject_sparse := DOP_smokeobject; 124 | SHELF_spyro_groundexplosion := SHELF_explosion; 125 | SHELF_spyro_airexplosion := SHELF_aerial_explosion; 126 | SHELF_spyro_fireball := SHELF_fireball; 127 | 128 | DOP_clothattachconstraint := DOP_clothattachtobody; 129 | 130 | DOP_null := COMMON_null; 131 | DOP_delete := COMMON_delete; 132 | DOP_file := COMMON_file; 133 | DOP_merge := COMMON_merge; 134 | DOP_subnet := COMMON_subnet; 135 | DOP_switch := COMMON_switch; 136 | 137 | DOP_anchoralignaxis := DOP_anchor; 138 | DOP_anchorobjpointgrouppos := DOP_anchor; 139 | DOP_anchorobjpointgrouprot := DOP_anchor; 140 | DOP_anchorobjpointidpos := DOP_anchor; 141 | DOP_anchorobjpointidrot := DOP_anchor; 142 | DOP_anchorobjpointnumpos := DOP_anchor; 143 | DOP_anchorobjpointnumrot := DOP_anchor; 144 | DOP_anchorobjprimpos := DOP_anchor; 145 | DOP_anchorobjregion := DOP_anchor; 146 | DOP_anchorobjspacepos := DOP_anchor; 147 | DOP_anchorobjspacerot := DOP_anchor; 148 | DOP_anchorobjsurfacepos := DOP_anchor; 149 | DOP_anchorobjsurfaceslide := DOP_anchor; 150 | DOP_anchortarget := DOP_anchor; 151 | DOP_anchorworldspacepos := DOP_anchor; 152 | DOP_anchorworldspacerot := DOP_anchor; 153 | 154 | DOP_conetwistconrel := DOP_rbdconetwistconstraint; 155 | DOP_conrelationship := DOP_applyrel; 156 | DOP_glueconrel := DOP_applyrel; 157 | DOP_grouprel := DOP_applyrel; 158 | DOP_hardconrel := DOP_applyrel; 159 | DOP_noconrel := DOP_applyrel; 160 | DOP_pumprel := SHELF_fluid_pump; 161 | DOP_sinkrel := SHELF_fluid_sink; 162 | DOP_sliderconrel := DOP_rbdsliderconstraint; 163 | DOP_softattachconrel := DOP_applyrel; 164 | DOP_sourcerel := SHELF_fluid_source; 165 | DOP_springconrel := CHOP_spring; 166 | DOP_targetrel := DOP_applyrel; 167 | DOP_twostateconrel := DOP_applyrel; 168 | DOP_colliderel := COMMON_collision; 169 | DOP_volumecollider := COMMON_collision; 170 | DOP_rbdguide := SHELF_rbd_guided; 171 | SOP_rbdguidesetup := SHELF_rbd_guided; 172 | 173 | DOP_sbdconstraint := DOP_constraint; 174 | DOP_femfuseconstraint := DOP_clothstitchconstraint; 175 | DOP_femregionconstraint := SHELF_constrain_region; 176 | 177 | DOP_femsolver := DOP_finiteelementsolver; 178 | DOP_femsolvercore := DOP_finiteelementsolver; 179 | DOP_femtargetconstraint := SHELF_cloth_follow_animation; 180 | DOP_gasupresobject := DOP_gasupres; 181 | 182 | DOP_output := VOP_output; 183 | DOP_subnetoutput := VOP_suboutput; 184 | POP_fluid := SHELF_convert_to_particlefluid; 185 | DOP_popgrains := SHELF_convert_to_particlegrain; 186 | DOP_constraintnetworkvisualization := SOP_rbdexplodedview; 187 | DOP_staticvisualization := MISC_visualize; 188 | DOP_volume := SOP_volume; 189 | DOP_whitewaterobject := SHELF_whitewater; 190 | DOP_impactanalysis := DOP_affector; 191 | 192 | COP2_render := ROP_mantra; 193 | COP2_vopcop2gen := COP2_vexgenerate; 194 | COP2_vopcop2filter := COP2_vexfilter; 195 | COP2_loop := CHOP_cycle; 196 | COP2_loopdata := BUTTONS_jump; 197 | IMG_img := NETWORKS_cop2; 198 | 199 | CHANNELS_hide_box := BUTTONS_reselect; 200 | CHANNELS_cut_keys := BUTTONS_cut; 201 | CHANNELS_copy_keys := BUTTONS_copy; 202 | CHANNELS_paste_keys := BUTTONS_paste; 203 | CHANNELS_delete_keys := BUTTONS_remove; 204 | CHANNELS_set_keys := PLAYBAR_set_key; 205 | 206 | CHANNELS_dope_cut := BUTTONS_cut; 207 | CHANNELS_dope_copy := BUTTONS_copy; 208 | CHANNELS_dope_paste := BUTTONS_paste; 209 | CHANNELS_dope_replace := BUTTONS_replace; 210 | CHANNELS_dope_delete := BUTTONS_delete; 211 | CHANNELS_dope_set_keys := PLAYBAR_set_key; 212 | 213 | SHELF_constraint_bake := CHANNELS_bake_keys; 214 | SHELF_constraint_delete := SOP_delete; 215 | SHELF_constraint_toggle := DATATYPES_boolean; 216 | 217 | CHOP_agent := CROWDS_agent; 218 | CHOP_blend := COMMON_blend; 219 | CHOP_comp := COP2_composite; 220 | CHOP_constraintblend := OBJ_blend; 221 | CHOP_constraintexport := CHOP_export; 222 | CHOP_constraintgetlocalspace := COP2_fetch; 223 | CHOP_constraintgetparentspace := COP2_fetch; 224 | CHOP_constraintgetworldspace := COP2_fetch; 225 | CHOP_constraintlookat := SHELF_lookat; 226 | CHOP_constraintobject := CHOP_object; 227 | CHOP_constraintobjectoffset := CHOP_object; 228 | CHOP_constraintobjectpretransform := CHOP_pretransform; 229 | CHOP_constraintpath := SHELF_followpath; 230 | CHOP_constraintparent := SHELF_parent; 231 | CHOP_constraintparentx := SHELF_parent; 232 | CHOP_constraintpoints := OBJ_rivet; 233 | CHOP_constraintsequence := OBJ_blend; 234 | CHOP_constraintsimpleblend := OBJ_blend; 235 | CHOP_constraintsurface := OBJ_sticky; 236 | CHOP_constrainttransform := CHOP_transform; 237 | CHOP_driver := CHOP_channel; 238 | CHOP_delete := COMMON_delete; 239 | CHOP_dynamicwarp := SOP_kinefx-dynamicwarp; 240 | CHOP_extend := COP2_extend; 241 | CHOP_extractbonetransforms := CHOP_transformchain; 242 | CHOP_fetch := COP2_fetch; 243 | CHOP_fetchchop := COP2_fetch; 244 | CHOP_fbx := ROP_fbx; 245 | CHOP_file := COMMON_file; 246 | CHOP_for := SOP_foreach; 247 | CHOP_function := COP2_function; 248 | CHOP_hold := CHOP_delay; 249 | CHOP_identity := VOP_constant; 250 | CHOP_invert := VOP_invert; 251 | CHOP_layer := COP2_layer; 252 | CHOP_lookup := COP2_lookup; 253 | CHOP_mouse := KEYS_mouse; 254 | CHOP_multiply := VOP_multiply; 255 | CHOP_noise := COP2_noise; 256 | CHOP_null := COMMON_null; 257 | CHOP_output := VOP_output; 258 | CHOP_rename := COP2_rename; 259 | CHOP_sequence := COP2_sequence; 260 | CHOP_shift := COP2_shift; 261 | CHOP_shuffle := COP2_shuffle; 262 | CHOP_stash := SOP_stash; 263 | CHOP_stashpose := SOP_kinefx-rigstashpose; 264 | CHOP_subnet := COMMON_subnet; 265 | CHOP_switch := COMMON_switch; 266 | CHOP_timeshift := SOP_timeshift; 267 | CHOP_timerange := COP2_timescale; 268 | CHOP_trim := COP2_trim; 269 | CHOP_vector := COP2_vector; 270 | CHOP_warp := COP2_warp; 271 | CHOPNET_ch := NETWORKS_chop; 272 | CHOP_pose := SOP_posespacedeform; 273 | 274 | OBJ_subnet := COMMON_subnet; 275 | OBJ_guidesim := FUR_add_dynamics; 276 | OBJ_indirectlight := OBJ_light_gi; 277 | OBJ_mcacclaim := SHELF_motion_capture; 278 | OBJ_viewportisolator := VIEW_display_objects; 279 | 280 | OBJ_STATE_pose := TOOLS_pose; 281 | 282 | OBJ_fur := SOP_fur; 283 | OBJ_haircardgen := FUR_haircardgen; 284 | 285 | OBJ_autorigs := OBJ_autorig; 286 | OBJ_autobonechaininterface := OBJ_autorig_blank; 287 | OBJ_autorigs_module_foot := OBJ_autorig_foot; 288 | OBJ_autorigs_module_hand := OBJ_autorig_hand; 289 | OBJ_autorigs_module_leg := OBJ_autorig_leg; 290 | OBJ_autorigs_module_arm := OBJ_autorig_arm; 291 | OBJ_autorigs_module_spine := OBJ_autorig_spine; 292 | OBJ_autorigs_module_head := OBJ_autorig_head; 293 | OBJ_autorigs_module_master := OBJ_character_placer; 294 | 295 | POP_null := COMMON_null; 296 | POP_subnet := COMMON_subnet; 297 | POP_switch := COMMON_switch; 298 | PART_popnet := NETWORKS_pop; 299 | 300 | POP_event := MISC_generic; 301 | POP_softbody := SOP_spring; 302 | POP_state := MISC_generic; 303 | POP_suppress := MISC_generic; 304 | POP_v_sprinkler := MISC_generic; 305 | POP_voppop := NETWORKS_vop_pop; 306 | POP_vex := NETWORKS_vop; 307 | 308 | SHOP_v_fluffy := SHELF_clouds; 309 | SHOP_v_metacloud := SHELF_clouds; 310 | SHOP_vopimage3d := SHELF_clouds; 311 | SHOP_v_gilight := OBJ_light_gi; 312 | SHOP_v_rayshadow := SHELF_shadow; 313 | SHOP_vopcvex := SHELF_factory; 314 | SHOP_vopshadow := SHELF_shadow; 315 | SHOP_mergecoshader := COMMON_merge; 316 | SHOP_subnet := COMMON_subnet; 317 | SHOP_switch := COMMON_switch; 318 | SHOP_v_textfog := SHELF_atmosphere; 319 | SHOP_vopfog := SHELF_atmosphere; 320 | SHOP_v_litfog := SHELF_atmosphere; 321 | SHOP_v_pathtracer := SHELF_atmosphere; 322 | SHOP_v_photontracer := SHELF_atmosphere; 323 | SHOP_v_uniform := SHELF_atmosphere; 324 | SHOP_v_zdepth := SHELF_atmosphere; 325 | SHOP_v_fastzmap := SHELF_atmosphere; 326 | 327 | SHOP_pyro := SHELF_fireball; 328 | 329 | VOP_addgroup := SOP_group; 330 | VOP_align := SOP_matchaxis; 331 | VOP_bakeexports := COMMON_bake; 332 | VOP_bbox := SOP_bound; 333 | VOP_bumpmap := COP2_bump; 334 | VOP_bumpnoise := COP2_bump; 335 | VOP_classcast := SOP_name; 336 | VOP_classicshadercore:= VOP_surfacemodel; 337 | VOP_copinput := NETWORKS_cop2; 338 | VOP_envmap := COP2_env; 339 | VOP_extractxform := COP2_xform; 340 | VOP_fakecaustics := OBJ_light_caustic; 341 | VOP_frontface := COP2_frontface; 342 | VOP_hair := SOP_fur; 343 | VOP_hasmetadata := COP2_metadata; 344 | VOP_image3d := SHELF_clouds; 345 | VOP_image3dvolume := SHELF_clouds; 346 | VOP_inline := NETWORKS_vop; 347 | VOP_irradiance := OBJ_light_environment; 348 | VOP_lighting := OBJ_light; 349 | VOP_lookat := SHELF_lookat; 350 | VOP_metadata := COP2_metadata; 351 | VOP_null := COMMON_null; 352 | VOP_orient := SOP_matchaxis; 353 | VOP_photon_output := VOP_output; 354 | VOP_principledshader := VOP_principled; 355 | VOP_principledshader-2.0 := VOP_principled; 356 | VOP_principledshadercore := VOP_principled; 357 | VOP_pyroshader := SHELF_fireball; 358 | VOP_rampparm := DATATYPES_ramp; 359 | VOP_shinymetal := VOP_reflective; 360 | VOP_spline := SOP_curve; 361 | VOP_subnet := COMMON_subnet; 362 | VOP_switch := COMMON_switch; 363 | VOP_texture := SOP_texture; 364 | VOP_usdglobal := COMMON_usd; 365 | VOP_usdtransform2d := SOP_uvtransform; 366 | VOP_uvproject := SOP_project; 367 | VOP_uvxfrom := SOP_uvtransform; 368 | VOP_transform := COP2_xform; 369 | VOP_snippet := COMMON_wrangle; 370 | VOP_renderstate := MISC_generic; 371 | VOP_hairmodel := SHELF_hair; 372 | VOP_block_begin := SOP_block_begin; 373 | VOP_block_end := SOP_block_end; 374 | VOP_hascopinput := VOP_isconnected; 375 | VOP_ocio_import := COMMON_opencolorio; 376 | VOP_ocio_transform := COMMON_opencolorio; 377 | VOP_diffuse := VOP_pbrdiffuse; 378 | VOP_pbrglass := VOP_glass; 379 | VOP_pbrhair_r := VOP_pbrhair; 380 | VOP_pbrhair_trt := VOP_pbrhair; 381 | VOP_pbrhair_tt := VOP_pbrhair; 382 | VOP_pbrlighting := OBJ_light; 383 | VOP_volumeshadercore := SHELF_smoke_heavy; 384 | VOP_materialbuilder := SHOP_material; 385 | VOP_ocean_samplelayers := SHELF_oceanwaves; 386 | VOP_shadercallexport := MISC_generic; 387 | VOP_unifiednoise_static := VOP_unifiednoise; 388 | VOP_hairshader := SHELF_vellum_hair2; 389 | 390 | VOP_kma_aov := VOP_kma_mtlx; 391 | VOP_kma_curvature := COMMON_houdinimaterialx; 392 | VOP_kma_hair := SHELF_vellum_hair3; 393 | VOP_kma_pyropreview:= VOP_pyroadvancedshader; 394 | VOP_kma_fakecaustics := OBJ_light_caustic; 395 | VOP_kma_nesteddielectrics := VOP_kma_mtlx; 396 | VOP_kma_rayhitlevelfalloff := VOP_kma_mtlx; 397 | VOP_kma_rayimport := VOP_kma_mtlx; 398 | VOP_kma_roundededge := COMMON_houdinimaterialx; 399 | VOP_kma_voronoinoise2d := COMMON_houdinimaterialx; 400 | VOP_kma_voronoinoise3d := COMMON_houdinimaterialx; 401 | VOP_kma_uvlens := VOP_physicallens; 402 | VOP_kma_ocio_transform := COMMON_opencolorio; 403 | VOP_kma_volume := COMMON_houdinimaterialx; 404 | 405 | VOP_kma_pyroanisotropy := MTLX_pyro_generic; 406 | VOP_kma_pyrofireemission := MTLX_pyro_generic; 407 | VOP_kma_pyroscattercolor := MTLX_pyro_generic; 408 | VOP_kma_pyroscatteremission := MTLX_pyro_generic; 409 | VOP_kma_pyroshader := MTLX_pyro_generic; 410 | 411 | VOP_hmtlxbias := COMMON_houdinimaterialx; 412 | VOP_hmtlxcolorcorrect := MTLX_colorcorrect; 413 | VOP_hmtlxcomplement := COMMON_houdinimaterialx; 414 | VOP_hmtlxcubicrampc := COMMON_houdinimaterialx; 415 | VOP_hmtlxcubicrampf := COMMON_houdinimaterialx; 416 | VOP_hmtlxfacingratio := COMMON_houdinimaterialx; 417 | VOP_hmtlxgain := COMMON_houdinimaterialx; 418 | VOP_hmtlxrampc := COMMON_houdinimaterialx; 419 | VOP_hmtlxrampf := COMMON_houdinimaterialx; 420 | 421 | VOP_kinefx-lookat := CROWDS_agentlookat; 422 | VOP_kinefx-lookatconstraint := CROWDS_agentlookat; 423 | VOP_kinefx-mappoint := SOP_kinefx-mappoints; 424 | VOP_kinefx-parentblend := OBJ_blend; 425 | VOP_kinefx-posedifference := CHOP_posedifference; 426 | VOP_kinefx-reversefoot := SOP_kinefx-reversefoot; 427 | 428 | ROP_fetch := COP2_fetch; 429 | ROP_merge := COMMON_merge; 430 | ROP_null := COMMON_null; 431 | ROP_subnet := COMMON_subnet; 432 | ROP_switch := COMMON_switch; 433 | 434 | ROP_ifd := ROP_mantra; 435 | ROP_rib := ROP_rman; 436 | ROP_mentalray := ROP_mental; 437 | ROP_alf := ROP_alfred; 438 | ROP_alembic := SOP_alembic; 439 | ROP_usd := COMMON_usd; 440 | 441 | ROP_bake_animation := CHANNELS_bake_keys; 442 | ROP_channel := NETWORKS_chop; 443 | ROP_code := MISC_generic; 444 | ROP_codedata := MISC_generic; 445 | ROP_comp := NETWORKS_cop2; 446 | ROP_dop := NETWORKS_dop; 447 | ROP_geometry := OBJ_geo; 448 | ROP_shell := SOP_unix; 449 | ROP_mdd := SOP_mdd; 450 | ROP_hq := SHELF_factory; 451 | ROP_karma := MISC_karma; 452 | 453 | ROP_kinefx-filmboxfbxcharacter := SOP_kinefx-rop_fbxcharacteroutput; 454 | ROP_kinefx-filmboxfbxanimation := SOP_kinefx-rop_fbxanimoutput; 455 | 456 | MPI_popvector := MPI_vector; 457 | MPI_limitbound := MPI_bound; 458 | 459 | SHELF_noise := DOP_noise; 460 | 461 | NODEFLAGS_unloaded := NODEFLAGS_unload; 462 | NODEFLAGS_loaded := NODEFLAGS_unload; 463 | 464 | BUTTONS_export_view_camera := OBJ_camera; 465 | BUTTONS_editstate_peak := SOP_peak; 466 | BUTTONS_match_pivot_rotation := BUTTONS_match_pivot; 467 | 468 | # Crowd remappings 469 | SOP_agentclip := CROWDS_agentclip; 470 | SOP_agentclipproperties := CROWDS_agentedit; 471 | SOP_agentedit := CROWDS_agentedit; 472 | SOP_agentprep := CROWDS_agent; 473 | SOP_agentcliptransitiongraph := CROWDS_transition; 474 | SOP_agentterrainadaptation := CROWDS_agentterrainadaptation; 475 | SOP_crowdsource := CROWDS_crowdsource; 476 | DOP_agentarcingcliplayer := CROWDS_agentedit; 477 | DOP_agentcliplayer := CROWDS_agentedit; 478 | DOP_agentlookat := CROWDS_agentlookat; 479 | DOP_agentlookatapply := CROWDS_agentlookat; 480 | DOP_agentterrainadaptation := CROWDS_agentterrainadaptation; 481 | DOP_agentterrainprojection := CROWDS_agentterrainprojection; 482 | DOP_crowdobject := CROWDS_agent; 483 | DOP_crowdsolver := CROWDS_crowdsolver; 484 | DOP_crowdstate := CROWDS_state; 485 | DOP_crowdtransition := CROWDS_transition; 486 | DOP_crowdtrigger := CROWDS_trigger; 487 | DOP_crowdtriggerlogic := CROWDS_triggerlogic; 488 | DOP_popsteeravoid := CROWDS_popsteeravoid; 489 | DOP_popsteerobstacle := CROWDS_popsteerobstacle; 490 | DOP_popsteerseek := CROWDS_popsteerseek; 491 | DOP_popsteerseparate := CROWDS_popsteerseparate; 492 | OBJ_agentcam := CROWDS_agentcam; 493 | OBJ_formationcrowdsexample := MISC_present; 494 | OBJ_fuzzyObstacleAvoidance := MISC_present; 495 | OBJ_fuzzystatetransition := MISC_present; 496 | OBJ_ragdollrunexample := MISC_present; 497 | OBJ_stadiumcrowdsexample := MISC_present; 498 | OBJ_streetcrowdsexample := MISC_present; 499 | 500 | # All gas DOPs use the same set, no few! 501 | DOP_bulletrbdsolver := DOP_bulletsolver; 502 | DOP_gasadaptiveviscosity := DOP_gas; 503 | DOP_gasadjustelasticity := DOP_gas; 504 | DOP_gasadjustparticlecoordinates := DOP_gas; 505 | DOP_gasadvect := DOP_gas; 506 | DOP_gasadvectcl := DOP_gas; 507 | DOP_gasanalysis := DOP_gas; 508 | DOP_gasattribswap := DOP_gas; 509 | DOP_gasblur := DOP_gas; 510 | DOP_gasbuildoccupancymask := DOP_gas; 511 | DOP_gasbuildrelationshipmask := DOP_gas; 512 | DOP_gasbuoyancy := DOP_gas; 513 | DOP_gascalculate := DOP_gas; 514 | DOP_gascollisiondetect := DOP_gas; 515 | DOP_gascombustion := DOP_gas; 516 | DOP_gascomputeparticleattributes := DOP_gas; 517 | DOP_gascorrectbymarkers := DOP_gas; 518 | DOP_gascross := DOP_gas; 519 | DOP_gasdamp := DOP_gas; 520 | DOP_gasdiffuse := DOP_gas; 521 | DOP_gasdissipate := DOP_gas; 522 | DOP_gasdisturb := DOP_gas; 523 | DOP_gasdisturbfieldcl := DOP_gas; 524 | DOP_gasdsd := DOP_gas; 525 | DOP_gaseachdatasolver := DOP_gas; 526 | DOP_gaselasticity := DOP_gas; 527 | DOP_gasenforceboundary := DOP_gas; 528 | DOP_gasexternalforces := DOP_gas; 529 | DOP_gasextrapolate := DOP_gas; 530 | DOP_gasfeedback := DOP_gas; 531 | DOP_gasfieldtoparticle := DOP_gas; 532 | DOP_gasfieldvex := DOP_gas; 533 | DOP_gasfieldvop := DOP_gas; 534 | DOP_gasfieldwrangle := DOP_gas; 535 | DOP_gasgeometrydefragment := DOP_gas; 536 | DOP_gasgeometryoptiontransfer := DOP_gas; 537 | DOP_gasgeometrytosdf := DOP_gas; 538 | DOP_gasguidingvolume := DOP_gas; 539 | DOP_gasimpacttoattributes := DOP_gas; 540 | DOP_gasintegrator := DOP_gas; 541 | DOP_gasinterleavesolver := DOP_gas; 542 | DOP_gasintermittentsolve := DOP_gas; 543 | DOP_gaslimit := DOP_gas; 544 | DOP_gaslimitparticles := DOP_gas; 545 | DOP_gaslinearcombination := DOP_gas; 546 | DOP_gaslookup := DOP_gas; 547 | DOP_gasmatchfield := DOP_gas; 548 | DOP_gasnetfetchdata := DOP_gas; 549 | DOP_gasnetfieldborderexchange := DOP_gas; 550 | DOP_gasnetfieldsliceexchange := DOP_gas; 551 | DOP_gasnetslicebalance := DOP_gas; 552 | DOP_gasnetsliceexchange := DOP_gas; 553 | DOP_gasopencl := DOP_gas; 554 | DOP_gasparticleforces := DOP_gas; 555 | DOP_gasparticlemovetoiso := DOP_gas; 556 | DOP_gasparticleneighbourupdate := DOP_gas; 557 | DOP_gasparticlepressure := DOP_gas; 558 | DOP_gasparticleseparate := DOP_gas; 559 | DOP_gasparticletofield := DOP_gas; 560 | DOP_gasparticletosdf := DOP_gas; 561 | DOP_gasprojectnondivergent := DOP_gas; 562 | DOP_gasprojectnondivergentmultigrid := DOP_gas; 563 | DOP_gasprojectnondivergentvariational := DOP_gas; 564 | DOP_gasreduce := DOP_gas; 565 | DOP_gasreducelocal := DOP_gas; 566 | DOP_gasreinitializesdf := DOP_gas; 567 | DOP_gasrepeatsolver := DOP_gas; 568 | DOP_gasresizefield := DOP_gas; 569 | DOP_gasresizefluiddynamic := DOP_gas; 570 | DOP_gasrest := DOP_gas; 571 | DOP_gassandforces := DOP_gas; 572 | DOP_gassdftofog := DOP_gas; 573 | DOP_gasseedmarkers := DOP_gas; 574 | DOP_gasseedparticles := DOP_gas; 575 | DOP_gasseedvolume := DOP_gas; 576 | DOP_gasshred := DOP_gas; 577 | DOP_gasslicetoindexfield := DOP_gas; 578 | DOP_gassolver := DOP_gas; 579 | DOP_gassphdensity := DOP_gas; 580 | DOP_gassphforces := DOP_gas; 581 | DOP_gasstrainforces := DOP_gas; 582 | DOP_gasstrainintegrate := DOP_gas; 583 | DOP_gassubstep := DOP_gas; 584 | DOP_gassurfacesnap := DOP_gas; 585 | DOP_gassurfacetension := DOP_gas; 586 | DOP_gassynchronizefields := DOP_gas; 587 | DOP_gasturbulence := DOP_gas; 588 | DOP_gasvelocitystretch := DOP_gas; 589 | DOP_gasviscosity := DOP_gas; 590 | DOP_gasvorticleforces := DOP_gas; 591 | DOP_gasvorticlegeometry := DOP_gas; 592 | DOP_gasvorticlerecycle := DOP_gas; 593 | DOP_gaswavelets := DOP_gas; 594 | DOP_popfireworks := POP_fireworks; 595 | DOP_popgroup := POP_group; 596 | DOP_popmetaballforce := SOP_force; 597 | DOP_popreplicate := POP_split; 598 | DOP_popsource := POP_source; 599 | DOP_rigidbodysolver := DOP_rbdsolver; 600 | DOP_visualizegeometry := SOP_visualize; 601 | 602 | # The SOP * Fields use the same as the non-SOP prefixed 603 | DOP_sopscalarfield := DOP_scalarfield; 604 | DOP_sopvectorfield := DOP_vectorfield; 605 | DOP_sopmatrixfield := DOP_matrixfield; 606 | DOP_sopindexfield := DOP_indexfield; 607 | 608 | IMAGE_auto_save := BUTTONS_auto_save; 609 | IMAGE_render_save := BUTTONS_save_image; 610 | 611 | # SHOP mappings 612 | # SHOP_surface - Has a new icon 613 | SHOP_surfaceshadow := VOP_surfaceshadow; 614 | SHOP_displace := SOP_mountain; 615 | SHOP_geometry := OBJ_geo; 616 | SHOP_light := OBJ_light; 617 | SHOP_shadow := VOP_shadow; 618 | SHOP_fog := SHOP_vopfog; 619 | SHOP_lens := OBJ_camera; 620 | SHOP_output := OBJ_camera; 621 | SHOP_background := VIEW_display_background; 622 | SHOP_texture := SOP_texture; 623 | SHOP_environment := COP2_env; 624 | SHOP_photon := MISC_generic; 625 | SHOP_emitter := MISC_generic; 626 | SHOP_generic := MISC_generic; 627 | SHOP_contour := MISC_generic; 628 | SHOP_contourstore := MISC_generic; 629 | SHOP_contourcontrast := MISC_generic; 630 | SHOP_image3d := SOP_volume; 631 | SHOP_properties := MISC_generic; 632 | SHOP_coshader := NETWORKS_generic; 633 | SHOP_cvex := DATATYPES_vex; 634 | 635 | SHOP_rsl_vopdisplace := SHOP_vopdisplace; 636 | SHOP_rsl_vopimager := OBJ_camera; 637 | SHOP_rsl_voplight := SHOP_voplight; 638 | SHOP_rsl_vopmaterial := SHOP_vopmaterial; 639 | SHOP_rsl_vopsurface := SHOP_vopsurface; 640 | SHOP_rsl_vopvolume := SHOP_vopfog; 641 | 642 | VOPNET_surface := SHOP_surface; 643 | VOPNET_displace := SOP_mountain; 644 | VOPNET_light := OBJ_light; 645 | VOPNET_fog := SHOP_vopfog; 646 | VOPNET_shadow := VOP_shadow; 647 | VOPNET_photon := MISC_generic; 648 | VOPNET_image3d := SOP_volume; 649 | VOPNET_cvex := DATATYPES_vex; 650 | 651 | VOPNET_rsl_surface := SHOP_surface; 652 | VOPNET_rsl_displace := SOP_mountain; 653 | VOPNET_rsl_imager := OBJ_camera; 654 | VOPNET_rsl_light := OBJ_light; 655 | VOPNET_rsl_volume := SHOP_vopfog; 656 | 657 | VOPNET_chop := NETWORKS_chop; 658 | VOPNET_cop2filter := NETWORKS_cop2; 659 | VOPNET_cop2gen := COP2_color; 660 | VOPNET_pop := NETWORKS_vop_pop; 661 | VOPNET_sop := NETWORKS_vop_sop; 662 | 663 | MWS_findmaterial := SHELF_find_material; 664 | 665 | SOP_agent := CROWDS_agent; 666 | SOP_agentproxy := CROWDS_agent; 667 | ROP_agent := CROWDS_agent; 668 | SOP_agentlayer := CROWDS_agentlayer; 669 | SOP_agentlookat := CROWDS_agentlookat; 670 | 671 | # All crowd VOPs get the same icon because 672 | # there's too many to make them custom 673 | VOP_agentaddclip := CROWDS_agent; 674 | VOP_agentclipcatalog := CROWDS_agent; 675 | VOP_agentcliplength := CROWDS_agent; 676 | VOP_agentclipnames := CROWDS_agent; 677 | VOP_agentclipsample := CROWDS_agent; 678 | VOP_agentclipsamplerate := CROWDS_agent; 679 | VOP_agentcliptimes := CROWDS_agent; 680 | VOP_agentclipweights := CROWDS_agent; 681 | VOP_agentconverttransforms := CROWDS_agent; 682 | VOP_agentlayerbindings := CROWDS_agent; 683 | VOP_agentlayername := CROWDS_agent; 684 | VOP_agentlayers := CROWDS_agent; 685 | VOP_agentlayershapes := CROWDS_agent; 686 | VOP_agentrigchildren := CROWDS_agent; 687 | VOP_agentrigfind := CROWDS_agent; 688 | VOP_agentrigparent := CROWDS_agent; 689 | VOP_agenttransformcount := CROWDS_agent; 690 | VOP_agenttransformnames := CROWDS_agent; 691 | VOP_agenttransforms := CROWDS_agent; 692 | 693 | VOP_setagentclipnames := CROWDS_agentedit; 694 | VOP_setagentcliptimes := CROWDS_agentedit; 695 | VOP_setagentclipweights := CROWDS_agentedit; 696 | VOP_setagentlayer := CROWDS_agentedit; 697 | VOP_setagenttransforms := CROWDS_agentedit; 698 | 699 | VOP_mtlxfakecaustics := OBJ_light_caustic; 700 | 701 | NETWORKS_topgeometry := NETWORKS_top; 702 | 703 | TOP_subnet := COMMON_subnet; 704 | TOP_null := COMMON_null; 705 | TOP_output := VOP_output; 706 | TOP_switch := COMMON_switch; 707 | TOP_errorhandler := MISC_bug; 708 | 709 | TOP_attributecopy := SOP_attribcopy; 710 | TOP_attributecreate := SOP_attribcreate; 711 | TOP_attributedelete := SOP_attribdelete; 712 | TOP_attributerandomize := SOP_attribrandomize; 713 | TOP_attributerename := SOP_attribute; 714 | 715 | TOP_mapall := TOP_gatherall; 716 | TOP_mapbyexpression := TOP_expression; 717 | TOP_mapbyindex := TOP_gatherbyindex; 718 | TOP_mapbyrange := TOP_gatherbyindex; 719 | 720 | TOP_merge := COMMON_merge; 721 | TOP_partitionbyattribute := TOP_partitionbydata; 722 | TOP_partitionbyexpression := TOP_expression; 723 | TOP_partitionbyindex := TOP_gatherbyindex; 724 | TOP_partitionbyrange := TOP_gatherbyindex; 725 | 726 | TOP_pythonmapper := MISC_python; 727 | TOP_pythonpartitioner := MISC_python; 728 | TOP_pythonprocessor := MISC_python; 729 | TOP_pythonscript := MISC_python; 730 | TOP_pythonserver := MISC_python; 731 | 732 | TOP_ropcomposite := NETWORKS_cop2; 733 | TOP_environmentedit := BUTTONS_environment; 734 | TOP_error := SOP_error; 735 | TOP_ffmpegencodevideo := TOP_ffmpeg; 736 | TOP_ffmpegextractimages := TOP_ffmpeg; 737 | TOP_filerange := TOP_filepattern; 738 | TOP_filerename := MISC_rename; 739 | TOP_invoke := SOP_invoke; 740 | TOP_inprocessscheduler := TOP_scheduler; 741 | TOP_hqueuescheduler := MISC_hqueue_logo; 742 | TOP_localscheduler := TOP_scheduler; 743 | TOP_pythonscheduler := TOP_scheduler; 744 | TOP_rendersetup := ROP_mantra; 745 | TOP_renderifd := ROP_mantra; 746 | TOP_ropalembic := SOP_alembic; 747 | TOP_ropfbx := ROP_fbx; 748 | TOP_ropgeometry := OBJ_geo; 749 | TOP_ropkarma := MISC_karma; 750 | TOP_ropusd := COMMON_usd; 751 | TOP_ropmantra := ROP_mantra; 752 | TOP_sort := MISC_generic; 753 | TOP_split := DOP_split; 754 | TOP_textoutput := VOP_print; 755 | TOP_usdaddassetstogallery := SHELF_asset_library; 756 | TOP_usdrender := ROP_usdrender; 757 | TOP_usdimport := LOP_sceneimport; 758 | TOP_usdimportfiles := LOP_sceneimport; 759 | TOP_usdimportprims := LOP_sceneimport; 760 | TOP_wedge := ROP_wedge; 761 | TOP_wedgegeo := ROP_wedge; 762 | TOP_workitemimport := COMMON_import; 763 | 764 | TOP_waitforall := TOP_gatherall; 765 | 766 | LOP_additionalrendervars := LOP_rendervar; 767 | LOP_assignmaterial := SOP_material; 768 | LOP_attribvop := SOP_attribvop; 769 | LOP_attribwrangle := SOP_attribwrangle; 770 | LOP_bakeskinning := COMMON_bake; 771 | LOP_blend := OBJ_blend; 772 | LOP_cache := MISC_generic; 773 | LOP_camera := OBJ_camera; 774 | LOP_capsule := SOP_capsule; 775 | LOP_cone := SOP_cone; 776 | LOP_copyproperty := VOP_copy; 777 | LOP_cube := SOP_box; 778 | LOP_cylinder := SOP_tube; 779 | LOP_distantlight := OBJ_light_directional; 780 | LOP_domelight := SCENEGRAPH_domelight; 781 | LOP_error := SOP_error; 782 | LOP_fetch := SOP_object_merge; 783 | LOP_followpathconstraint := SHELF_followpath; 784 | LOP_foreach_begin := SOP_block_begin; 785 | LOP_foreach_end := SOP_block_end; 786 | LOP_insertionpoint := MISC_starthere; 787 | LOP_instancer := SOP_copytopoints; 788 | LOP_karma := MISC_karma; 789 | LOP_karmafogbox := SHELF_fog; 790 | LOP_light := OBJ_hlight; 791 | LOP_lookatconstraint := SHELF_lookat; 792 | LOP_mantradomelight := OBJ_light_environment; 793 | LOP_mantralight := OBJ_light; 794 | LOP_materialassign := SHOP_material; 795 | LOP_null := COMMON_null; 796 | LOP_object := OBJ_geo; 797 | LOP_optionblock_begin := SOP_compile_begin; 798 | LOP_optionblock_end := SOP_compile_end; 799 | LOP_output := VOP_output; 800 | LOP_parentconstraint := SHELF_parent_constraint; 801 | LOP_pointsconstraint := OBJ_rivet; 802 | LOP_prune := SOP_clean; 803 | LOP_pythonscript := MISC_python; 804 | LOP_retimeinstances := SOP_retime; 805 | LOP_sopmodify := SOP_edit; 806 | LOP_sphere := SOP_sphere; 807 | LOP_subnet := COMMON_subnet; 808 | LOP_surfaceconstraint := OBJ_sticky; 809 | LOP_switch := COMMON_switch; 810 | LOP_xform := SOP_xform; 811 | LOP_transformuv := SOP_uvtransform; 812 | LOP_volume := SOP_volume; 813 | 814 | NETWORKS_object := NETWORKS_obj; 815 | NETWORKS_driver := NETWORKS_rop; 816 | NETWORKS_stage := NETWORKS_lop; 817 | 818 | SCENEGRAPH_drawmode_overridden := MISC_override; 819 | SCENEGRAPH_overridden_and_postlayer := MISC_postlayer_and_override; 820 | SCENEGRAPH_something_overridden := MISC_override; 821 | SCENEGRAPH_something_postlayer := MISC_postlayer; 822 | SCENEGRAPH_collection := LOP_collection; 823 | 824 | TOOLS_select_obj_visible := TOOLS_select_visible; 825 | TOOLS_select_obj_contained := TOOLS_select_contained; 826 | TOOLS_select_obj_material := VIEW_materials; 827 | TOOLS_select_obj_constraints := NETWORKS_chop; 828 | TOOLS_select_obj_child := BUTTONS_down; 829 | TOOLS_select_obj_parent := BUTTONS_up; 830 | TOOLS_select_type_geo := TOOLS_select_objects; 831 | TOOLS_select_type_camera := TOOLS_view; 832 | TOOLS_select_type_light := VIEW_lighting_normal; 833 | TOOLS_select_type_null := OBJ_null; 834 | TOOLS_select_type_bone := NODEFLAGS_bone; 835 | TOOLS_select_type_other := NETVIEW_info_button_selected; 836 | TOOLS_select_show_name_hover := VIEW_display_objectnames_name; 837 | TOOLS_select_mask_geo := TOOLS_select_objects; 838 | 839 | VIEW_quickview_save_1 := NETVIEW_quickmark_set_1; 840 | VIEW_quickview_save_2 := NETVIEW_quickmark_set_2; 841 | VIEW_quickview_save_3 := NETVIEW_quickmark_set_3; 842 | VIEW_quickview_save_4 := NETVIEW_quickmark_set_4; 843 | VIEW_quickview_save_5 := NETVIEW_quickmark_set_5; 844 | VIEW_quickview_load_1 := NETVIEW_quickmark_go_1; 845 | VIEW_quickview_load_2 := NETVIEW_quickmark_go_2; 846 | VIEW_quickview_load_3 := NETVIEW_quickmark_go_3; 847 | VIEW_quickview_load_4 := NETVIEW_quickmark_go_4; 848 | VIEW_quickview_load_5 := NETVIEW_quickmark_go_5; 849 | VIEW_quickview_load_prev := NETVIEW_quickmark_back; 850 | VIEW_quickplane_save_1 := NETVIEW_quickmark_set_1; 851 | VIEW_quickplane_save_2 := NETVIEW_quickmark_set_2; 852 | VIEW_quickplane_save_3 := NETVIEW_quickmark_set_3; 853 | VIEW_quickplane_save_4 := NETVIEW_quickmark_set_4; 854 | VIEW_quickplane_save_5 := NETVIEW_quickmark_set_5; 855 | VIEW_quickplane_load_1 := NETVIEW_quickmark_go_1; 856 | VIEW_quickplane_load_2 := NETVIEW_quickmark_go_2; 857 | VIEW_quickplane_load_3 := NETVIEW_quickmark_go_3; 858 | VIEW_quickplane_load_4 := NETVIEW_quickmark_go_4; 859 | VIEW_quickplane_load_5 := NETVIEW_quickmark_go_5; 860 | VIEW_quickplane_load_prev := NETVIEW_quickmark_back; 861 | 862 | NETVIEW_kinematics_badge := CHOP_iksolver; 863 | NETVIEW_kinematics_badge_large := CHOP_iksolver; 864 | NETVIEW_needscook_badge := NETVIEW_reload_needsupdate; 865 | NETVIEW_needscook_badge_large := NETVIEW_reload_needsupdate; 866 | 867 | SHELF_character_picker := PANETYPES_custom; 868 | SHELF_pose_library := PANETYPES_custom; 869 | SHELF_flipconfigureflip := SOP_testgeometry_rubbertoy; 870 | SHELF_collsion := COMMON_collision; 871 | SHELF_crowd := CROWDS_crowdobject; 872 | SHELF_robot := MISC_robot; 873 | 874 | SOP_layoutbrushplace := SHELF_pick_and_place; 875 | 876 | -------------------------------------------------------------------------------- /ui/icons/closed.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /ui/icons/empty.svg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/golubevcg/hip_file_diff_tool/fb5de338d53cf69b8bd857e65f048c740b3a20f2/ui/icons/empty.svg -------------------------------------------------------------------------------- /ui/icons/end.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /ui/icons/folder.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/golubevcg/hip_file_diff_tool/fb5de338d53cf69b8bd857e65f048c740b3a20f2/ui/icons/folder.png -------------------------------------------------------------------------------- /ui/icons/icons.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/golubevcg/hip_file_diff_tool/fb5de338d53cf69b8bd857e65f048c740b3a20f2/ui/icons/icons.zip -------------------------------------------------------------------------------- /ui/icons/more.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /ui/icons/opened.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /ui/icons/search.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/golubevcg/hip_file_diff_tool/fb5de338d53cf69b8bd857e65f048c740b3a20f2/ui/icons/search.png -------------------------------------------------------------------------------- /ui/icons/vline.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /ui/recursive_filter_proxy_model.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, Set 2 | 3 | from hutil.Qt.QtCore import QSortFilterProxyModel, QModelIndex 4 | from hutil.Qt.QtGui import QStandardItem 5 | 6 | from ui.constants import DATA_ROLE, PATH_ROLE 7 | from api.data.item_data import ItemState 8 | 9 | 10 | class RecursiveFilterProxyModel(QSortFilterProxyModel): 11 | """ 12 | Subclass of QSortFilterProxyModel that enables recursive filtering. 13 | Provides custom behaviors like path-specific filtering. 14 | """ 15 | 16 | def __init__(self, *args, **kwargs): 17 | super().__init__(*args, **kwargs) 18 | self.path_role = PATH_ROLE 19 | self.data_role = DATA_ROLE 20 | self._filtered_paths: Set[str] = set() 21 | 22 | def filterAcceptsRow( 23 | self, source_row: int, source_parent: QModelIndex 24 | ) -> bool: 25 | """Check if a row in the source model should be included in the proxy model.""" 26 | source_index = self.sourceModel().index(source_row, 0, source_parent) 27 | item_path = self.sourceModel().data(source_index, self.path_role) 28 | 29 | # If there's an active filter for paths and the item's path isn't in it, reject this row. 30 | if self._filtered_paths and item_path not in self._filtered_paths: 31 | return False 32 | 33 | # If source model has a condition to show only edited items 34 | if ( 35 | hasattr(self.sourceModel(), "show_only_edited") 36 | and self.sourceModel().show_only_edited 37 | ): 38 | if not self.conditionForItem(source_index): 39 | return False 40 | 41 | # Check if the current row matches the filter itself 42 | if self.filter_accepts_row_itself(source_row, source_parent): 43 | return True 44 | 45 | # Recursively check child items 46 | for i in range(self.sourceModel().rowCount(source_index)): 47 | if self.filterAcceptsRow(i, source_index): 48 | return True 49 | 50 | # make sure that value is shown if parent fits condition 51 | state_value = self.sourceModel().data(source_index, self.data_role).state 52 | if state_value == ItemState.VALUE and source_parent.isValid(): 53 | if self.filter_accepts_row_itself(source_parent.row(), source_parent.parent()): 54 | return True 55 | 56 | return False 57 | 58 | def conditionForItem(self, index: QModelIndex) -> bool: 59 | """ 60 | Check the condition for a given item. 61 | 62 | :param index: QModelIndex representing the item. 63 | :return: True if the item matches the condition, False otherwise. 64 | """ 65 | state_value = self.sourceModel().data(index, self.data_role).state 66 | if state_value not in [ItemState.UNCHANGED]: 67 | return True 68 | 69 | for i in range(self.sourceModel().rowCount(index)): 70 | child_index = self.sourceModel().index(i, 0, index) 71 | if self.conditionForItem(child_index): 72 | return True 73 | 74 | return False 75 | 76 | def filter_accepts_row_itself( 77 | self, source_row: int, source_parent: QModelIndex 78 | ) -> bool: 79 | """Check if the source row itself meets the filter criteria.""" 80 | return super().filterAcceptsRow(source_row, source_parent) 81 | 82 | def itemFromIndex(self, proxy_index: QModelIndex) -> QStandardItem: 83 | """Retrieve the item from the source model corresponding to the given proxy index.""" 84 | source_index = self.mapToSource(proxy_index) 85 | return self.sourceModel().itemFromIndex(source_index) 86 | 87 | def indexFromItem(self, item: QStandardItem) -> QModelIndex: 88 | """Retrieve the proxy model index corresponding to the given QStandardItem.""" 89 | source_index = self.sourceModel().indexFromItem(item) 90 | return self.mapFromSource(source_index) 91 | 92 | def get_item_by_path(self, path: str) -> Optional[QStandardItem]: 93 | """ 94 | Retrieve an item by its unique path. 95 | 96 | :param path: Unique path identifier for the item. 97 | :return: QStandardItem if found, otherwise None. 98 | """ 99 | item_dictionary = getattr(self.sourceModel(), "item_dictionary", None) 100 | return item_dictionary.get(path) if item_dictionary else None 101 | 102 | def set_filtered_paths(self, paths: Set[str]) -> None: 103 | """ 104 | Define a set of paths to filter by. 105 | 106 | :param paths: Set of paths to be used for filtering. 107 | """ 108 | self._filtered_paths = paths 109 | self.invalidateFilter() 110 | 111 | def reset_proxy_view(self) -> None: 112 | """Reset the view by clearing filters and sorting.""" 113 | self.set_filtered_paths(set()) # Clear the paths filter 114 | self.setFilterFixedString("") 115 | self.sort(-1) 116 | self.invalidateFilter() 117 | -------------------------------------------------------------------------------- /ui/search_line_edit.py: -------------------------------------------------------------------------------- 1 | import os 2 | from typing import Set, Optional 3 | 4 | from hutil.Qt.QtWidgets import QWidget, QLineEdit, QAbstractItemView, QAction 5 | from hutil.Qt.QtCore import Qt 6 | from hutil.Qt.QtGui import QPixmap, QIcon 7 | 8 | from ui.constants import PATH_ROLE, ICONS_PATH 9 | from ui.recursive_filter_proxy_model import RecursiveFilterProxyModel 10 | 11 | 12 | class QTreeViewSearch(QLineEdit): 13 | """Search widget for filtering items within a QTreeView.""" 14 | 15 | def __init__( 16 | self, treeview, target_model, parent: Optional[QWidget] = None 17 | ): 18 | super().__init__(parent) 19 | self.init_ui(treeview, target_model) 20 | self.init_events() 21 | self.init_styles() 22 | 23 | def init_ui(self, treeview, target_model): 24 | """Initialize user interface components.""" 25 | self.treeview = treeview 26 | self.target_model = target_model 27 | self.proxy_model = RecursiveFilterProxyModel(self.treeview) 28 | self.proxy_model.setSourceModel(self.target_model) 29 | self.target_model.proxy_model = self.proxy_model 30 | self.treeview.setModel(self.proxy_model) 31 | 32 | self.expanded_state = {} 33 | self.setPlaceholderText("Search") 34 | 35 | pixmap = QPixmap(os.path.join(ICONS_PATH, "search.png")) 36 | self.search_action = QAction(self) 37 | self.search_action.setIcon(QIcon(pixmap)) 38 | self.addAction(self.search_action, QLineEdit.TrailingPosition) 39 | 40 | self.secondary_proxy_model = None 41 | self.secondary_treeview = None 42 | self.second_search = None 43 | 44 | def init_events(self): 45 | """Connect UI events to their handlers.""" 46 | self.search_action.triggered.connect(self.filter_tree_view) 47 | self.textChanged.connect(self.filter_tree_view) 48 | self.returnPressed.connect(self.select_first_match) 49 | 50 | def init_styles(self): 51 | """Set the visual styles for the search widget.""" 52 | self.setStyleSheet( 53 | """ 54 | QLineEdit{ 55 | font: 10pt "Arial"; 56 | color: #818181; 57 | background-color: white; 58 | border-radius: 10px; 59 | padding: 2px 5px; 60 | } 61 | QLineEdit:hover, QLineEdit:selected { 62 | color: #919191; 63 | background-color: white; 64 | } 65 | """ 66 | ) 67 | 68 | def filter_tree_view(self): 69 | """Apply filter on tree view based on search input.""" 70 | self.proxy_model.reset_proxy_view() 71 | self.secondary_proxy_model.reset_proxy_view() 72 | 73 | search_text = self.text().strip() 74 | if not search_text: 75 | self.restore_tree_state() 76 | if self.second_search: 77 | self.second_search.restore_tree_state() 78 | return 79 | 80 | self.proxy_model.setFilterRole(Qt.DisplayRole) 81 | self.proxy_model.setFilterFixedString(search_text) 82 | self.proxy_model.setFilterCaseSensitivity(Qt.CaseInsensitive) 83 | 84 | self.proxy_model.invalidateFilter() 85 | self.synchronize_trees() 86 | 87 | def synchronize_trees(self): 88 | """Keep the primary and secondary tree views in sync.""" 89 | visible_paths = self.get_visible_paths() 90 | self.filter_secondary_tree(visible_paths) 91 | 92 | def get_visible_paths(self) -> Set[str]: 93 | """Retrieve paths that are visible in the primary tree view.""" 94 | visible_paths = set() 95 | root = self.proxy_model.index(0, 0) 96 | self.collect_paths(root, visible_paths) 97 | return visible_paths 98 | 99 | def collect_paths(self, index, paths: Set[str]): 100 | """Recursively gather paths visible from the given index.""" 101 | if not index.isValid(): 102 | return 103 | 104 | paths.add(self.proxy_model.data(index, PATH_ROLE)) 105 | for row in range(self.proxy_model.rowCount(index)): 106 | child_index = self.proxy_model.index(row, 0, index) 107 | self.collect_paths(child_index, paths) 108 | 109 | def filter_secondary_tree(self, paths: Set[str]): 110 | """Update items in the secondary tree view based on provided paths.""" 111 | self.secondary_proxy_model.setFilterFixedString("") 112 | if not paths: 113 | self.secondary_proxy_model.setFilterFixedString( 114 | "ImpossibleStringThatMatchesNothing" 115 | ) 116 | return 117 | 118 | if self.secondary_proxy_model: 119 | self.secondary_proxy_model.set_filtered_paths(paths) 120 | self.proxy_model.set_filtered_paths(paths) 121 | self.secondary_treeview.expandAll() 122 | 123 | def select_first_match(self): 124 | """Highlight the first item in tree view that matches the search.""" 125 | first_index = self.proxy_model.index(0, 0) 126 | if first_index.isValid(): 127 | self.treeview.setCurrentIndex(first_index) 128 | self.treeview.scrollTo( 129 | first_index, QAbstractItemView.PositionAtTop 130 | ) 131 | 132 | def capture_tree_state(self): 133 | """Remember the current expanded/collapsed state of tree view items.""" 134 | for row in range(self.treeview.model().rowCount()): 135 | index = self.treeview.model().index(row, 0) 136 | self._capture_state(index) 137 | 138 | def _capture_state(self, index): 139 | """Recursively store the state of tree view items.""" 140 | if index.isValid(): 141 | path = self.treeview.model().data(index, PATH_ROLE) 142 | self.expanded_state[path] = self.treeview.isExpanded(index) 143 | for row in range(self.treeview.model().rowCount(index)): 144 | child_index = self.treeview.model().index(row, 0, index) 145 | self._capture_state(child_index) 146 | 147 | def restore_tree_state(self): 148 | """Restore the expanded/collapsed state of tree view items.""" 149 | for row in range(self.treeview.model().rowCount()): 150 | index = self.treeview.model().index(row, 0) 151 | self._restore_state(index) 152 | 153 | def _restore_state(self, index): 154 | """Recursively restore the state of tree view items.""" 155 | if index.isValid(): 156 | path = self.treeview.model().data(index, PATH_ROLE) 157 | self.treeview.setExpanded(index, self.expanded_state.get(path, False)) 158 | for row in range(self.treeview.model().rowCount(index)): 159 | child_index = self.treeview.model().index(row, 0, index) 160 | self._restore_state(child_index) 161 | 162 | def focusInEvent(self, event): 163 | """Handle the focus-in event and capture the tree state.""" 164 | super().focusInEvent(event) 165 | if not self.text(): 166 | self.capture_tree_state() 167 | if self.second_search: 168 | self.second_search.capture_tree_state() 169 | 170 | -------------------------------------------------------------------------------- /ui/string_diff_dialog.py: -------------------------------------------------------------------------------- 1 | import difflib 2 | 3 | from hutil.Qt.QtWidgets import QDialog, QTextEdit, QVBoxLayout, QHBoxLayout 4 | from hutil.Qt.QtGui import QColor, QPalette, QColor 5 | from hutil.Qt.QtWidgets import ( 6 | QDialog, 7 | QWidget, 8 | QTextEdit, 9 | QVBoxLayout, 10 | QSplitter, 11 | QPushButton, 12 | QLineEdit 13 | ) 14 | from hutil.Qt.QtCore import Qt, QTimer, QEvent 15 | 16 | from ui.constants import PATH_ROLE 17 | from ui.hatched_text_edit import HatchedTextEdit 18 | from ui.ui_utils import generate_link_to_clipboard 19 | from api.comparators.houdini_base_comparator import COLORS 20 | 21 | 22 | class Overlay(QWidget): 23 | def __init__(self, parent: QWidget = None): 24 | """ 25 | A semi-transparent overlay widget that covers its parent widget. 26 | 27 | Args: 28 | parent (QWidget): The parent widget over which the overlay is placed. Defaults to None. 29 | """ 30 | super(Overlay, self).__init__(parent) 31 | palette = QPalette(self.palette()) 32 | palette.setColor(palette.Background, QColor(15, 15, 15, 128)) 33 | self.setPalette(palette) 34 | self.setAutoFillBackground(True) 35 | 36 | def resizeEvent(self, event) -> None: 37 | """ 38 | Handles the resize event to ensure the overlay covers the entire parent widget. 39 | 40 | Args: 41 | event: The resize event. 42 | """ 43 | self.resize(self.parent().size()) 44 | super().resizeEvent(event) 45 | 46 | 47 | class StringDiffDialog(QDialog): 48 | def __init__(self, index, other_index, parent=None): 49 | """ 50 | A dialog for displaying the differences between two strings. 51 | 52 | Args: 53 | index: The model index of the first string. 54 | other_index: The model index of the second string. 55 | parent (QWidget): The parent widget of the dialog. Defaults to None. 56 | """ 57 | super().__init__(parent) 58 | self.parent_application = parent 59 | 60 | self.setupUI(index, other_index) 61 | 62 | def setupUI(self, index, other_index) -> None: 63 | """ 64 | Sets up the user interface of the dialog. 65 | 66 | Args: 67 | index: The model index of the first string. 68 | other_index: The model index of the second string. 69 | """ 70 | self.setWindowTitle("String diff tool") 71 | 72 | source_text = index.data(Qt.DisplayRole) 73 | target_text = other_index.data(Qt.DisplayRole) 74 | 75 | self.setGeometry(300, 300, 1200, 600) 76 | 77 | self.setStyleSheet( 78 | """ 79 | QDialog{ 80 | background-color: #333333; 81 | } 82 | 83 | QTextEdit{ 84 | font: 10pt "DS Houdini"; 85 | color: #dfdfdf; 86 | background-color: #333333; 87 | border:none; 88 | } 89 | """ 90 | ) 91 | 92 | self.top_buttons_widget = QWidget() 93 | self.top_buttons_widget.setStyleSheet( 94 | """ 95 | background-color: #404040; 96 | border: 1px solid #4d4d4d; 97 | border-radius: 15px; 98 | """ 99 | ) 100 | self.top_buttons_widget.setFixedHeight(40) 101 | self.top_buttons_widget.setContentsMargins(5, 1, 0, 0) 102 | 103 | self.top_buttons_hbox_layout = QHBoxLayout(self.top_buttons_widget) 104 | self.top_buttons_hbox_layout.setContentsMargins(0, 0, 0, 0) 105 | 106 | self.copy_link_button = QPushButton("copy link", self) 107 | self.copy_link_button.setObjectName("copyLink") 108 | self.copy_link_button.setFixedHeight(30) 109 | self.copy_link_button.setFixedWidth(120) 110 | self.copy_link_button.setStyleSheet( 111 | """ 112 | QPushButton#copyLink { 113 | font: 10pt "Arial"; 114 | color: #919191; 115 | border:none; 116 | border-right: 1px solid #4d4d4d; 117 | border-radius:0px; 118 | } 119 | QPushButton#copyLink:hover { 120 | border: 1px solid rgb(185, 134, 32); 121 | border-radius:10px; 122 | } 123 | """ 124 | ) 125 | self.copy_link_button.clicked.connect(self._handle_copy_link) 126 | 127 | self.copy_path_button = QPushButton("copy path", self) 128 | self.copy_path_button.setObjectName("copyLink") 129 | self.copy_path_button.setFixedHeight(30) 130 | self.copy_path_button.setFixedWidth(120) 131 | self.copy_path_button.setStyleSheet( 132 | """ 133 | QPushButton#copyLink { 134 | font: 10pt "Arial"; 135 | color: #919191; 136 | border:none; 137 | border-radius:0px; 138 | } 139 | QPushButton#copyLink:hover { 140 | border: 1px solid rgb(185, 134, 32); 141 | border-radius:10px; 142 | } 143 | """ 144 | ) 145 | self.copy_path_button.clicked.connect(self._handle_copy_path) 146 | 147 | self._copy_link_timer = QTimer(self) 148 | self._copy_link_timer.timeout.connect(self.reset_link_button_text) 149 | self._copy_link_timer.setSingleShot(True) 150 | 151 | self._copy_path_timer = QTimer(self) 152 | self._copy_path_timer.timeout.connect(self.reset_path_button_text) 153 | self._copy_path_timer.setSingleShot(True) 154 | 155 | path_to_node = index.data(PATH_ROLE) 156 | self.node_path_line_edit = QLineEdit(path_to_node) 157 | self.node_path_line_edit.setContentsMargins(0, 0, 3, 0) 158 | 159 | self.node_path_line_edit.setFixedHeight(35) 160 | self.node_path_line_edit.setReadOnly(True) 161 | self.node_path_line_edit.setStyleSheet( 162 | """ 163 | font: 10pt "DS Houdini"; 164 | color: #919191; 165 | background-color: #333333; 166 | border:none; 167 | border-radius: 15px; 168 | padding-left:15px; 169 | padding-bottom:3px; 170 | """ 171 | ) 172 | 173 | self.top_buttons_hbox_layout.addWidget(self.copy_link_button) 174 | self.top_buttons_hbox_layout.addWidget(self.copy_path_button) 175 | self.top_buttons_hbox_layout.addWidget(self.node_path_line_edit) 176 | 177 | # Split texts into lines for difflib processing 178 | old_lines = source_text.splitlines() 179 | new_lines = target_text.splitlines() 180 | 181 | # Get diffs 182 | diff = difflib.Differ() 183 | diffs = list(diff.compare(old_lines, new_lines)) 184 | 185 | 186 | self.new_text_hashed_line_numbers = [] 187 | self.old_text_hashed_line_numbers = [] 188 | # Process the diffs and get formatted strings for both QTextEdits 189 | old_html, new_html = self.process_diffs(diffs) 190 | 191 | # Create text edits and set their content 192 | self.line_nums_qtedit = QTextEdit(self) 193 | self.line_nums_qtedit.setReadOnly(True) 194 | self.line_nums_qtedit.setLineWrapMode(QTextEdit.NoWrap) 195 | self.line_nums_qtedit.setFixedWidth(60) 196 | self.line_nums_qtedit.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) 197 | self.line_nums_qtedit.setStyleSheet( 198 | """ 199 | font: 10pt "DS Houdini"; 200 | color: #dfdfdf; 201 | background-color: #333333; 202 | border-right: 1px solid #4d4d4d; 203 | """ 204 | ) 205 | 206 | line_nums = [] 207 | for lin_num in range(0, len(old_html)): 208 | line_nums.append(f'
{str(lin_num)}
') 209 | self.line_nums_qtedit.setHtml(''.join(line_nums)) 210 | 211 | text_edit_stylesheet = """ 212 | QTextEdit { 213 | font: 10pt "DS Houdini"; 214 | color: #dfdfdf; 215 | background-color: #333333; 216 | } 217 | QScrollBar:vertical { 218 | border: none; 219 | background: #333333; 220 | width: 20px; 221 | border: 1px solid #3c3c3c; 222 | } 223 | QScrollBar::handle:vertical { 224 | background: #464646; 225 | min-width: 20px; 226 | } 227 | QScrollBar::sub-line:vertical, QScrollBar::add-line:vertical { 228 | border: none; 229 | background: none; 230 | height: 0; 231 | subcontrol-position: top; 232 | subcontrol-origin: margin; 233 | } 234 | QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical { 235 | background: none; 236 | } 237 | 238 | QScrollBar:horizontal { 239 | border: none; 240 | background: #333333; 241 | height: 15px; 242 | border: 1px solid #3c3c3c; 243 | } 244 | QScrollBar::handle:horizontal { 245 | background: #464646; 246 | min-height: 15px; 247 | } 248 | QScrollBar::sub-line:horizontal, QScrollBar::add-line:horizontal { 249 | border: none; 250 | background: none; 251 | width: 0; 252 | subcontrol-position: top; 253 | subcontrol-origin: margin; 254 | } 255 | QScrollBar::add-page:horizontal, QScrollBar::sub-page:horizontal { 256 | background: none; 257 | } 258 | """ 259 | 260 | # Create text edits and set their content 261 | self.old_text_edit = HatchedTextEdit(self) 262 | self.old_text_edit.setReadOnly(True) 263 | self.old_text_edit.setLineWrapMode(QTextEdit.NoWrap) 264 | self.old_text_edit.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) 265 | self.old_text_edit.setStyleSheet(text_edit_stylesheet) 266 | self.old_text_edit.setHtml(''.join(old_html)) 267 | self.old_text_edit.text_lines = old_html 268 | 269 | widget = QWidget() 270 | hlayout = QHBoxLayout(widget) 271 | hlayout.setSpacing(0) 272 | hlayout.setContentsMargins(0, 0, 0, 0) 273 | hlayout.addWidget(self.line_nums_qtedit) 274 | hlayout.addWidget(self.old_text_edit) 275 | 276 | self.new_text_edit = HatchedTextEdit(self) 277 | self.new_text_edit.setReadOnly(True) 278 | self.new_text_edit.setLineWrapMode(QTextEdit.NoWrap) 279 | self.new_text_edit.setHtml(''.join(new_html)) 280 | self.new_text_edit.text_lines = new_html 281 | self.new_text_edit.setStyleSheet(text_edit_stylesheet) 282 | 283 | # Create a splitter and add text edits to it 284 | self.splitter = QSplitter(Qt.Horizontal, self) 285 | self.splitter.addWidget(widget) 286 | self.splitter.addWidget(self.new_text_edit) 287 | self.splitter.setHandleWidth(1) 288 | self.splitter.setStyleSheet(""" 289 | QSplitter::handle { 290 | background-color: #4d4d4d; 291 | } 292 | """) 293 | 294 | # Layouts and widgets setup 295 | layout = QVBoxLayout(self) 296 | layout.setSpacing(0) 297 | layout.setContentsMargins(5, 5, 5, 5) 298 | layout.addWidget(self.top_buttons_widget) 299 | layout.addWidget(self.splitter) 300 | self.setLayout(layout) 301 | 302 | self.overlay = Overlay(self.parent_application) 303 | self.overlay.show() 304 | 305 | self.old_text_edit.verticalScrollBar().valueChanged.connect( 306 | self.sync_scroll 307 | ) 308 | 309 | self.new_text_edit.verticalScrollBar().valueChanged.connect( 310 | self.sync_scroll 311 | ) 312 | 313 | self.old_text_edit.horizontalScrollBar().valueChanged.connect( 314 | self.sync_scroll 315 | ) 316 | 317 | self.new_text_edit.horizontalScrollBar().valueChanged.connect( 318 | self.sync_scroll 319 | ) 320 | 321 | # Point 1: Lock the child window to the center of the parent window 322 | self.centerOnParent() 323 | 324 | def centerOnParent(self) -> None: 325 | """ 326 | Centers the dialog on its parent widget. 327 | """ 328 | if self.parent(): 329 | parent_geometry = self.parent().geometry() 330 | self.setGeometry( 331 | parent_geometry.x() + (parent_geometry.width() - self.width()) / 2, 332 | parent_geometry.y() + (parent_geometry.height() - self.height()) / 2, 333 | self.width(), 334 | self.height() 335 | ) 336 | 337 | def closeEvent(self, event) -> None: 338 | """ 339 | Handles the close event of the dialog, ensuring the overlay is also closed. 340 | 341 | Args: 342 | event: The close event. 343 | """ 344 | self.overlay.close() 345 | super().closeEvent(event) 346 | 347 | def eventFilter(self, obj, event) -> bool: 348 | """ 349 | Filters events for the dialog, specifically to handle parent movement. 350 | Listen to the parent's move events and adjust the position 351 | of the child window accordingly. 352 | 353 | Args: 354 | obj: The object that received the event. 355 | event: The event that occurred. 356 | 357 | Returns: 358 | bool: True if the event should be ignored; False otherwise. 359 | """ 360 | if obj == self.parent() and event.type() == QEvent.Move: 361 | self.centerOnParent() 362 | return super().eventFilter(obj, event) 363 | 364 | def process_diffs(self, diffs) -> (list, list): 365 | """ 366 | Processes a list of diffs to generate formatted HTML strings for display. 367 | 368 | Args: 369 | diffs (list): A list of diffs generated by difflib. 370 | 371 | Returns: 372 | tuple: A tuple containing two lists of HTML strings representing the diffs. 373 | """ 374 | old_html = [] 375 | new_html = [] 376 | 377 | green_with_50_alpha = "#%s" + COLORS["green"][1:] 378 | red_with_50_alpha = "#%s" + COLORS["red"][1:] 379 | 380 | for diff in diffs: 381 | opcode = diff[0] 382 | text = diff[2:] 383 | 384 | text_display = text if text.strip() != "" else " " 385 | if opcode == ' ': 386 | old_html.append(f'
{text_display}
') 387 | new_html.append(f'
{text_display}
') 388 | elif opcode == '-': 389 | old_html.append(f'
{text_display}
') 390 | new_html.append(f'
 
') 391 | elif opcode == '+': 392 | old_html.append(f'
 
') 393 | new_html.append(f'
{text_display}
') 394 | 395 | return old_html, new_html 396 | 397 | def sync_scroll(self, value: int) -> None: 398 | """ 399 | Synchronizes the scrolling between two text edits. 400 | 401 | Args: 402 | value (int): The scroll position. 403 | """ 404 | # Fetch the source of the signal 405 | source_scrollbar = self.sender() 406 | 407 | # Determine the target scrollbar for synchronization 408 | if source_scrollbar == self.old_text_edit.verticalScrollBar(): 409 | target_scrollbar = self.new_text_edit.verticalScrollBar() 410 | elif source_scrollbar == self.new_text_edit.verticalScrollBar(): 411 | target_scrollbar = self.old_text_edit.verticalScrollBar() 412 | 413 | elif source_scrollbar == self.old_text_edit.horizontalScrollBar(): 414 | target_scrollbar = self.new_text_edit.horizontalScrollBar() 415 | elif source_scrollbar == self.new_text_edit.horizontalScrollBar(): 416 | target_scrollbar = self.old_text_edit.horizontalScrollBar() 417 | 418 | # Update the target's scrollbar position to match the source's 419 | target_scrollbar.setValue(value) 420 | 421 | def _handle_copy_link(self) -> None: 422 | """ 423 | Handles the "copy link" button click event. 424 | """ 425 | generate_link_to_clipboard( 426 | self.parent_application, 427 | self.node_path_line_edit.text() 428 | ) 429 | self.copy_link_button.setText("link copied") 430 | self._copy_link_timer.start(2500) 431 | 432 | def _handle_copy_path(self) -> None: 433 | """ 434 | Handles the "copy path" button click event. 435 | """ 436 | self.copy_path_button.setText("path copied") 437 | self.parent_application.clipboard.setText(self.node_path_line_edit.text()) 438 | self._copy_path_timer.start(2500) 439 | 440 | def reset_link_button_text(self) -> None: 441 | """ 442 | Resets the text of the "copy link" button. 443 | """ 444 | self.copy_link_button.setText("copy link") 445 | 446 | def reset_path_button_text(self) -> None: 447 | """ 448 | Resets the text of the "copy path" button. 449 | """ 450 | self.copy_path_button.setText("copy path") -------------------------------------------------------------------------------- /ui/ui_utils.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | from hutil.Qt.QtWidgets import QMessageBox 4 | 5 | 6 | def generate_link_to_clipboard(qapplication: 'QApplication', item_path: str) -> None: 7 | """ 8 | Generates a link for an item to launch a hip file difference tool and copies it to the clipboard. 9 | 10 | Parameters: 11 | qapplication (QApplication): The main application instance where UI elements are accessed. 12 | item_path (str): The path of the item for which the link is generated. 13 | 14 | The function retrieves source and target file paths from the application's line edits, 15 | checks for the necessary executable paths, constructs the command, and copies it to the clipboard. 16 | """ 17 | source_file_path = qapplication.source_file_line_edit.text() 18 | target_file_path = qapplication.target_file_line_edit.text() 19 | if not source_file_path or not target_file_path: 20 | QMessageBox.warning( 21 | qapplication, 22 | "Invalid Paths", 23 | "Source file or target files are empty, cannot generate link", 24 | ) 25 | return 26 | 27 | hython_executable_path = os.environ.get('HOUDINI_HYTHON', sys.executable) 28 | diff_tool_path = os.environ.get('HOUDINI_AGOL_DIFF_TOOL', qapplication.main_path) 29 | 30 | link = ( 31 | f'& "{hython_executable_path}" ' 32 | f'{diff_tool_path} ' 33 | f'--source={source_file_path} ' 34 | f'--target={target_file_path} ' 35 | f'--item-path={item_path}' 36 | ) 37 | qapplication.clipboard.setText(link) 38 | --------------------------------------------------------------------------------