├── README.md └── ifc-git.py /README.md: -------------------------------------------------------------------------------- 1 | # ifc-git 2 | *BlenderBIM Git support* 3 | 4 | **IMPORTANT NOTE, GIT SUPPORT IS NOW IN BLENDERBIM, THIS REPOSITORY IS DEFUNCT** 5 | 6 | ## Installation 7 | 8 | Download the file and install it using the Blender add-on preferences. 9 | 10 | You will need [BlenderBIM](https://blenderbim.org/) and [GitPython](https://gitpython.readthedocs.io/en/stable/). 11 | For the experimental branch merging feature you will need [ifcmerge](https://github.com/brunopostle/ifcmerge). 12 | 13 | ## Usage 14 | 15 | A new *IFC Git* panel is addded to *Scene > Properties*. 16 | If your IFC file in BlenderBIM is saved in a local Git repository you will be able to browse branches and revisions, loading any past version. 17 | The panel offers functionality to create a Git repository if it doesn't already exist. 18 | 19 | Saved changes can be committed or discarded. 20 | Committing changes to an earlier revision forces the creation of a branch, forking the project. 21 | Under some circumstances forked branches can be merged together. 22 | 23 | External changes to the Git repository made using other tools, such as pulling remote branches, are reflected in the addon and don't require restarting Blender. 24 | 25 | The diff functionality highlights *Products* that exist in the current revision that are different or which don't exist in the selected revision. 26 | 27 | 2023 Bruno Postle 28 | -------------------------------------------------------------------------------- /ifc-git.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | import git 4 | import bpy 5 | import time 6 | from blenderbim.bim.ifc import IfcStore 7 | import blenderbim.tool as tool 8 | 9 | 10 | bl_info = { 11 | "name": "IFC Git", 12 | "author": "Bruno Postle", 13 | "location": "Scene > IFC Git", 14 | "description": "Manage IFC files in Git repositories", 15 | "blender": (2, 80, 0), 16 | "category": "Import-Export", 17 | } 18 | 19 | # This program is free software; you can redistribute it and/or 20 | # modify it under the terms of the GNU General Public License 21 | # as published by the Free Software Foundation; either version 2 22 | # of the License, or (at your option) any later version. 23 | # 24 | # This program is distributed in the hope that it will be useful, 25 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 26 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 27 | # GNU General Public License for more details. 28 | # 29 | # You should have received a copy of the GNU General Public License 30 | # along with this program; if not, write to the Free Software Foundation, 31 | # Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. 32 | # 33 | # 2023 Bruno Postle 34 | 35 | # GUI CLASSES 36 | 37 | 38 | class IFCGIT_PT_panel(bpy.types.Panel): 39 | """Scene Properties panel to interact with IFC repository data""" 40 | 41 | bl_label = "IFC Git" 42 | bl_space_type = "PROPERTIES" 43 | bl_region_type = "WINDOW" 44 | bl_context = "scene" 45 | bl_options = {"DEFAULT_CLOSED"} 46 | bl_parent_id = "BIM_PT_project_info" 47 | 48 | def draw(self, context): 49 | layout = self.layout 50 | path_ifc = bpy.data.scenes["Scene"].BIMProperties.ifc_file 51 | 52 | # TODO if file isn't saved, offer to save to disk 53 | 54 | row = layout.row() 55 | if path_ifc: 56 | # FIXME shouldn't be a global 57 | global ifcgit_repo 58 | ifcgit_repo = repo_from_path(path_ifc) 59 | if ifcgit_repo: 60 | name_ifc = os.path.relpath(path_ifc, ifcgit_repo.working_dir) 61 | row.label(text=ifcgit_repo.working_dir, icon="SYSTEM") 62 | if name_ifc in ifcgit_repo.untracked_files: 63 | row.operator( 64 | "ifcgit.addfile", 65 | text="Add '" + name_ifc + "' to repository", 66 | icon="FILE", 67 | ) 68 | else: 69 | row.label(text=name_ifc, icon="FILE") 70 | else: 71 | row.operator( 72 | "ifcgit.createrepo", 73 | text="Create '" + os.path.dirname(path_ifc) + "' repository", 74 | icon="SYSTEM", 75 | ) 76 | row.label(text=os.path.basename(path_ifc), icon="FILE") 77 | return 78 | else: 79 | row.label(text="No Git repository found", icon="SYSTEM") 80 | row.label(text="No IFC project saved", icon="FILE") 81 | return 82 | 83 | is_dirty = ifcgit_repo.is_dirty(path=path_ifc) 84 | 85 | if is_dirty: 86 | row = layout.row() 87 | row.label(text="Saved changes have not been committed", icon="ERROR") 88 | 89 | row = layout.row() 90 | row.operator("ifcgit.display_uncommitted", icon="SELECT_DIFFERENCE") 91 | row.operator("ifcgit.discard", icon="TRASH") 92 | 93 | row = layout.row() 94 | row.prop(context.scene, "commit_message") 95 | 96 | if ifcgit_repo.head.is_detached: 97 | row = layout.row() 98 | row.label( 99 | text="HEAD is detached, commit will create a branch", icon="ERROR" 100 | ) 101 | row.prop(context.scene, "new_branch_name") 102 | 103 | row = layout.row() 104 | row.operator("ifcgit.commit_changes", icon="GREASEPENCIL") 105 | 106 | row = layout.row() 107 | if ifcgit_repo.head.is_detached: 108 | row.label(text="Working branch: Detached HEAD") 109 | else: 110 | row.label(text="Working branch: " + ifcgit_repo.active_branch.name) 111 | 112 | grouped = layout.row() 113 | column = grouped.column() 114 | row = column.row() 115 | row.prop(bpy.context.scene, "display_branch", text="Browse branch") 116 | row.prop(bpy.context.scene, "ifcgit_filter", text="Filter revisions") 117 | 118 | row = column.row() 119 | row.template_list( 120 | "COMMIT_UL_List", 121 | "The_List", 122 | context.scene, 123 | "ifcgit_commits", 124 | context.scene, 125 | "commit_index", 126 | ) 127 | column = grouped.column() 128 | row = column.row() 129 | row.operator("ifcgit.refresh", icon="FILE_REFRESH") 130 | 131 | if not is_dirty: 132 | 133 | row = column.row() 134 | row.operator("ifcgit.display_revision", icon="SELECT_DIFFERENCE") 135 | 136 | row = column.row() 137 | row.operator("ifcgit.switch_revision", icon="CURRENT_FILE") 138 | 139 | # TODO operator to tag selected 140 | 141 | row = column.row() 142 | row.operator("ifcgit.merge", icon="EXPERIMENTAL", text="") 143 | 144 | if not context.scene.ifcgit_commits: 145 | return 146 | 147 | item = context.scene.ifcgit_commits[context.scene.commit_index] 148 | commit = ifcgit_repo.commit(rev=item.hexsha) 149 | 150 | if not item.relevant: 151 | row = layout.row() 152 | row.label(text="Revision unrelated to current IFC project", icon="ERROR") 153 | 154 | box = layout.box() 155 | column = box.column(align=True) 156 | row = column.row() 157 | row.label(text=commit.hexsha) 158 | row = column.row() 159 | row.label(text=commit.author.name + " <" + commit.author.email + ">") 160 | row = column.row() 161 | row.label(text=commit.message) 162 | 163 | 164 | class ListItem(bpy.types.PropertyGroup): 165 | """Group of properties representing an item in the list.""" 166 | 167 | hexsha: bpy.props.StringProperty( 168 | name="Git hash", 169 | description="checksum for this commit", 170 | default="Uncommitted data!", 171 | ) 172 | relevant: bpy.props.BoolProperty( 173 | name="Is relevant", 174 | description="does this commit reference our ifc file", 175 | default=False, 176 | ) 177 | 178 | 179 | class COMMIT_UL_List(bpy.types.UIList): 180 | """List of Git commits""" 181 | 182 | def draw_item( 183 | self, context, layout, data, item, icon, active_data, active_propname, index 184 | ): 185 | 186 | current_revision = ifcgit_repo.commit() 187 | commit = ifcgit_repo.commit(rev=item.hexsha) 188 | 189 | lookup = branches_by_hexsha(ifcgit_repo) 190 | refs = "" 191 | if item.hexsha in lookup: 192 | for branch in lookup[item.hexsha]: 193 | if branch.name == context.scene.display_branch: 194 | refs = "[" + branch.name + "] " 195 | 196 | lookup = tags_by_hexsha(ifcgit_repo) 197 | if item.hexsha in lookup: 198 | for tag in lookup[item.hexsha]: 199 | refs += "{" + tag.name + "} " 200 | 201 | if commit == current_revision: 202 | layout.label( 203 | text="[HEAD] " + refs + commit.message, icon="DECORATE_KEYFRAME" 204 | ) 205 | else: 206 | layout.label(text=refs + commit.message, icon="DECORATE_ANIMATE") 207 | layout.label(text=time.strftime("%c", time.localtime(commit.committed_date))) 208 | 209 | 210 | # OPERATORS 211 | 212 | 213 | class CreateRepo(bpy.types.Operator): 214 | """Initialise a Git repository""" 215 | 216 | bl_label = "Create Git repository" 217 | bl_idname = "ifcgit.createrepo" 218 | bl_options = {"REGISTER"} 219 | 220 | @classmethod 221 | def poll(cls, context): 222 | path_ifc = bpy.data.scenes["Scene"].BIMProperties.ifc_file 223 | if not os.path.isfile(path_ifc): 224 | return False 225 | if repo_from_path(path_ifc): 226 | # repo already exists 227 | return False 228 | if re.match("^/home/[^/]+/?$", os.path.dirname(path_ifc)): 229 | # don't make ${HOME} a repo 230 | return False 231 | return True 232 | 233 | def execute(self, context): 234 | 235 | path_ifc = bpy.data.scenes["Scene"].BIMProperties.ifc_file 236 | path_dir = os.path.abspath(os.path.dirname(path_ifc)) 237 | git.Repo.init(path_dir) 238 | 239 | return {"FINISHED"} 240 | 241 | 242 | class AddFileToRepo(bpy.types.Operator): 243 | """Add a file to a repository""" 244 | 245 | bl_label = "Add file to repository" 246 | bl_idname = "ifcgit.addfile" 247 | bl_options = {"REGISTER"} 248 | 249 | @classmethod 250 | def poll(cls, context): 251 | path_ifc = bpy.data.scenes["Scene"].BIMProperties.ifc_file 252 | if not os.path.isfile(path_ifc): 253 | return False 254 | if not repo_from_path(path_ifc): 255 | # repo doesn't exist 256 | return False 257 | return True 258 | 259 | def execute(self, context): 260 | 261 | path_ifc = bpy.data.scenes["Scene"].BIMProperties.ifc_file 262 | repo = repo_from_path(path_ifc) 263 | repo.index.add(path_ifc) 264 | repo.index.commit( 265 | message="Added " + os.path.relpath(path_ifc, repo.working_dir) 266 | ) 267 | 268 | bpy.ops.ifcgit.refresh() 269 | 270 | return {"FINISHED"} 271 | 272 | 273 | class DiscardUncommitted(bpy.types.Operator): 274 | """Discard saved changes and update to HEAD""" 275 | 276 | bl_label = "Discard uncommitted changes" 277 | bl_idname = "ifcgit.discard" 278 | bl_options = {"REGISTER"} 279 | 280 | def execute(self, context): 281 | 282 | path_ifc = bpy.data.scenes["Scene"].BIMProperties.ifc_file 283 | # NOTE this is calling the git binary in a subprocess 284 | ifcgit_repo.git.checkout(path_ifc) 285 | load_project(path_ifc) 286 | 287 | return {"FINISHED"} 288 | 289 | 290 | class CommitChanges(bpy.types.Operator): 291 | """Commit current saved changes""" 292 | 293 | bl_label = "Commit changes" 294 | bl_idname = "ifcgit.commit_changes" 295 | bl_options = {"REGISTER"} 296 | 297 | @classmethod 298 | def poll(cls, context): 299 | if context.scene.commit_message == "": 300 | return False 301 | if ifcgit_repo.head.is_detached and ( 302 | not is_valid_ref_format(context.scene.new_branch_name) 303 | or context.scene.new_branch_name 304 | in [branch.name for branch in ifcgit_repo.branches] 305 | ): 306 | return False 307 | return True 308 | 309 | def execute(self, context): 310 | 311 | path_ifc = bpy.data.scenes["Scene"].BIMProperties.ifc_file 312 | ifcgit_repo.index.add(path_ifc) 313 | ifcgit_repo.index.commit(message=context.scene.commit_message) 314 | context.scene.commit_message = "" 315 | 316 | if ifcgit_repo.head.is_detached: 317 | new_branch = ifcgit_repo.create_head(context.scene.new_branch_name) 318 | new_branch.checkout() 319 | context.scene.display_branch = context.scene.new_branch_name 320 | context.scene.new_branch_name = "" 321 | 322 | bpy.ops.ifcgit.refresh() 323 | 324 | return {"FINISHED"} 325 | 326 | 327 | class RefreshGit(bpy.types.Operator): 328 | """Refresh revision list""" 329 | 330 | bl_label = "" 331 | bl_idname = "ifcgit.refresh" 332 | bl_options = {"REGISTER"} 333 | 334 | @classmethod 335 | def poll(cls, context): 336 | if "ifcgit_repo" in globals() and ifcgit_repo != None and ifcgit_repo.heads: 337 | return True 338 | return False 339 | 340 | def execute(self, context): 341 | 342 | area = next(area for area in bpy.context.screen.areas if area.type == "VIEW_3D") 343 | area.spaces[0].shading.color_type = "MATERIAL" 344 | 345 | # ifcgit_commits is registered list widget 346 | context.scene.ifcgit_commits.clear() 347 | 348 | path_ifc = bpy.data.scenes["Scene"].BIMProperties.ifc_file 349 | 350 | commits = list( 351 | git.objects.commit.Commit.iter_items( 352 | repo=ifcgit_repo, 353 | rev=[context.scene.display_branch], 354 | ) 355 | ) 356 | commits_relevant = list( 357 | git.objects.commit.Commit.iter_items( 358 | repo=ifcgit_repo, 359 | rev=[context.scene.display_branch], 360 | paths=[path_ifc], 361 | ) 362 | ) 363 | lookup = tags_by_hexsha(ifcgit_repo) 364 | 365 | for commit in commits: 366 | 367 | if context.scene.ifcgit_filter == "tagged" and not commit.hexsha in lookup: 368 | continue 369 | elif ( 370 | context.scene.ifcgit_filter == "relevant" 371 | and not commit in commits_relevant 372 | ): 373 | continue 374 | 375 | context.scene.ifcgit_commits.add() 376 | context.scene.ifcgit_commits[-1].hexsha = commit.hexsha 377 | if commit in commits_relevant: 378 | context.scene.ifcgit_commits[-1].relevant = True 379 | 380 | return {"FINISHED"} 381 | 382 | 383 | class DisplayRevision(bpy.types.Operator): 384 | """Colourise objects by selected revision""" 385 | 386 | bl_label = "" 387 | bl_idname = "ifcgit.display_revision" 388 | bl_options = {"REGISTER"} 389 | 390 | def execute(self, context): 391 | 392 | path_ifc = bpy.data.scenes["Scene"].BIMProperties.ifc_file 393 | item = context.scene.ifcgit_commits[context.scene.commit_index] 394 | 395 | selected_revision = ifcgit_repo.commit(rev=item.hexsha) 396 | current_revision = ifcgit_repo.commit() 397 | 398 | if selected_revision == current_revision: 399 | area = next( 400 | area for area in bpy.context.screen.areas if area.type == "VIEW_3D" 401 | ) 402 | area.spaces[0].shading.color_type = "MATERIAL" 403 | return {"FINISHED"} 404 | 405 | if current_revision.committed_date > selected_revision.committed_date: 406 | step_ids = ifc_diff_ids( 407 | ifcgit_repo, selected_revision.hexsha, current_revision.hexsha, path_ifc 408 | ) 409 | else: 410 | step_ids = ifc_diff_ids( 411 | ifcgit_repo, current_revision.hexsha, selected_revision.hexsha, path_ifc 412 | ) 413 | 414 | modified_shape_object_step_ids = get_modified_shape_object_step_ids(step_ids) 415 | 416 | final_step_ids = {} 417 | final_step_ids["added"] = step_ids["added"] 418 | final_step_ids["removed"] = step_ids["removed"] 419 | final_step_ids["modified"] = step_ids["modified"].union( 420 | modified_shape_object_step_ids["modified"] 421 | ) 422 | 423 | colourise(final_step_ids) 424 | 425 | return {"FINISHED"} 426 | 427 | 428 | class DisplayUncommitted(bpy.types.Operator): 429 | """Colourise uncommitted objects""" 430 | 431 | bl_label = "Show uncommitted changes" 432 | bl_idname = "ifcgit.display_uncommitted" 433 | bl_options = {"REGISTER"} 434 | 435 | def execute(self, context): 436 | 437 | path_ifc = bpy.data.scenes["Scene"].BIMProperties.ifc_file 438 | step_ids = ifc_diff_ids(ifcgit_repo, None, "HEAD", path_ifc) 439 | colourise(step_ids) 440 | 441 | return {"FINISHED"} 442 | 443 | 444 | class SwitchRevision(bpy.types.Operator): 445 | """Switches the repository to the selected revision and reloads the IFC file""" 446 | 447 | bl_label = "" 448 | bl_idname = "ifcgit.switch_revision" 449 | bl_options = {"REGISTER"} 450 | 451 | # FIXME bad things happen when switching to a revision that predates current project 452 | 453 | def execute(self, context): 454 | 455 | path_ifc = bpy.data.scenes["Scene"].BIMProperties.ifc_file 456 | item = context.scene.ifcgit_commits[context.scene.commit_index] 457 | 458 | lookup = branches_by_hexsha(ifcgit_repo) 459 | if item.hexsha in lookup: 460 | for branch in lookup[item.hexsha]: 461 | if branch.name == context.scene.display_branch: 462 | branch.checkout() 463 | else: 464 | # NOTE this is calling the git binary in a subprocess 465 | ifcgit_repo.git.checkout(item.hexsha) 466 | 467 | load_project(path_ifc) 468 | 469 | return {"FINISHED"} 470 | 471 | 472 | class Merge(bpy.types.Operator): 473 | """Merges the selected branch into working branch""" 474 | 475 | bl_label = "Merge this branch" 476 | bl_idname = "ifcgit.merge" 477 | bl_options = {"REGISTER"} 478 | 479 | def execute(self, context): 480 | 481 | path_ifc = bpy.data.scenes["Scene"].BIMProperties.ifc_file 482 | item = context.scene.ifcgit_commits[context.scene.commit_index] 483 | 484 | config_reader = ifcgit_repo.config_reader() 485 | section = 'mergetool "ifcmerge"' 486 | if not config_reader.has_section(section): 487 | config_writer = ifcgit_repo.config_writer() 488 | config_writer.set_value( 489 | section, "cmd", "ifcmerge $BASE $LOCAL $REMOTE $MERGED" 490 | ) 491 | config_writer.set_value(section, "trustExitCode", True) 492 | 493 | lookup = branches_by_hexsha(ifcgit_repo) 494 | if item.hexsha in lookup: 495 | for branch in lookup[item.hexsha]: 496 | if branch.name == context.scene.display_branch: 497 | # this is a branch! 498 | try: 499 | # NOTE this is calling the git binary in a subprocess 500 | ifcgit_repo.git.merge(branch) 501 | except git.exc.GitCommandError: 502 | # merge is expected to fail, run ifcmerge 503 | try: 504 | ifcgit_repo.git.mergetool(tool="ifcmerge") 505 | except: 506 | # ifcmerge failed, rollback 507 | ifcgit_repo.git.merge(abort=True) 508 | # FIXME need to report errors somehow 509 | 510 | self.report({"ERROR"}, "IFC Merge failed") 511 | return {"CANCELLED"} 512 | except: 513 | 514 | self.report({"ERROR"}, "Unknown IFC Merge failure") 515 | return {"CANCELLED"} 516 | 517 | ifcgit_repo.index.add(path_ifc) 518 | context.scene.commit_message = ( 519 | "Merged branch: " + context.scene.display_branch 520 | ) 521 | context.scene.display_branch = ifcgit_repo.active_branch.name 522 | 523 | load_project(path_ifc) 524 | 525 | return {"FINISHED"} 526 | else: 527 | return {"CANCELLED"} 528 | 529 | 530 | # FUNCTIONS 531 | 532 | 533 | def is_valid_ref_format(string): 534 | """Check a bare branch or tag name is valid""" 535 | 536 | return re.match( 537 | "^(?!\.| |-|/)((?!\.\.)(?!.*/\.)(/\*|/\*/)*(?!@\{)[^\~\:\^\\\ \?*\[])+(?