├── .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 | 
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 | 
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 |
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 |
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 |
14 |
--------------------------------------------------------------------------------
/ui/icons/opened.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
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 |
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'