From 6c18ac96053de87e1b24881184bc4734b6f0030f Mon Sep 17 00:00:00 2001 From: Bruno Postle Date: Thu, 2 Apr 2026 22:42:29 +0100 Subject: [PATCH 01/16] ifcgit: use --prioritise-local flag for ifcmerge-forward mergetool --- src/bonsai/bonsai/tool/ifcgit.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/bonsai/bonsai/tool/ifcgit.py b/src/bonsai/bonsai/tool/ifcgit.py index 1843716f58..91bbf1ac47 100644 --- a/src/bonsai/bonsai/tool/ifcgit.py +++ b/src/bonsai/bonsai/tool/ifcgit.py @@ -473,10 +473,15 @@ class IfcGit: config_writer.set_value(section, "cmd", "ifcmerge $BASE $LOCAL $REMOTE $MERGED") config_writer.set_value(section, "trustExitCode", True) section = 'mergetool "ifcmerge-forward"' + new_cmd = "ifcmerge --prioritise-local $BASE $LOCAL $REMOTE $MERGED" + old_cmd = "ifcmerge $BASE $REMOTE $LOCAL $MERGED" if not config_reader.has_section(section): with IfcGitRepo.repo.config_writer() as config_writer: - config_writer.set_value(section, "cmd", "ifcmerge $BASE $REMOTE $LOCAL $MERGED") + config_writer.set_value(section, "cmd", new_cmd) config_writer.set_value(section, "trustExitCode", True) + elif config_reader.get_value(section, "cmd") == old_cmd: + with IfcGitRepo.repo.config_writer() as config_writer: + config_writer.set_value(section, "cmd", new_cmd) @classmethod def config_push(cls, repo: git.Repo) -> None: From bcc631bf5fa3a2bb4dd4ed604f7994a3f8146a25 Mon Sep 17 00:00:00 2001 From: Bruno Postle Date: Thu, 2 Apr 2026 22:44:56 +0100 Subject: [PATCH 02/16] ifcgit: improve colourise to find products via geometry and property changes Generated with the assistance of an AI coding tool. --- src/bonsai/bonsai/tool/ifcgit.py | 41 +++++++++++++++++++++++++------- 1 file changed, 32 insertions(+), 9 deletions(-) diff --git a/src/bonsai/bonsai/tool/ifcgit.py b/src/bonsai/bonsai/tool/ifcgit.py index 91bbf1ac47..3cb9a7f227 100644 --- a/src/bonsai/bonsai/tool/ifcgit.py +++ b/src/bonsai/bonsai/tool/ifcgit.py @@ -388,20 +388,43 @@ class IfcGit: model = tool.Ifc.get() modified_step_ids = {"modified": set()} - for step_id in step_ids["modified"] | step_ids["added"]: - try: - entity = model.by_id(step_id) - except: - continue - if entity.is_a("IfcProductDefinitionShape"): + def collect(entity, depth=0): + if depth > 2: + return + if entity.is_a("IfcProduct"): + modified_step_ids["modified"].add(entity.id()) + elif entity.is_a("IfcProductDefinitionShape"): for product in entity.ShapeOfProduct: modified_step_ids["modified"].add(product.id()) elif entity.is_a("IfcObjectPlacement"): for product in entity.PlacesObject: modified_step_ids["modified"].add(product.id()) - elif entity.is_a("IfcTypeProduct") and entity.Types: - for related_object in entity.Types[0].RelatedObjects: - modified_step_ids["modified"].add(related_object.id()) + elif entity.is_a("IfcTypeProduct"): + for rel in entity.Types: + for obj in rel.RelatedObjects: + modified_step_ids["modified"].add(obj.id()) + elif entity.is_a("IfcShapeRepresentation"): + for prod_rep in entity.OfProductRepresentation: + for product in prod_rep.ShapeOfProduct: + modified_step_ids["modified"].add(product.id()) + elif entity.is_a("IfcRepresentationItem"): + for referencing in model.get_inverse(entity): + if referencing.is_a("IfcShapeRepresentation"): + collect(referencing, depth + 1) + elif entity.is_a("IfcPropertySet"): + for rel in entity.DefinesOccurrence: + for obj in rel.RelatedObjects: + modified_step_ids["modified"].add(obj.id()) + elif entity.is_a("IfcProperty"): + for pset in entity.PartOfPset: + collect(pset, depth + 1) + + for step_id in step_ids["modified"] | step_ids["added"]: + try: + entity = model.by_id(step_id) + except: + continue + collect(entity) return modified_step_ids From be3aef59fa0b5eed9f76ff71e547add6c6820025 Mon Sep 17 00:00:00 2001 From: Bruno Postle Date: Thu, 2 Apr 2026 22:45:33 +0100 Subject: [PATCH 03/16] ifcgit: move action buttons below revision list in a labelled row Generated with the assistance of an AI coding tool. --- .../bonsai/bim/module/ifcgit/operator.py | 6 +++--- src/bonsai/bonsai/bim/module/ifcgit/ui.py | 20 +++++-------------- 2 files changed, 8 insertions(+), 18 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/ifcgit/operator.py b/src/bonsai/bonsai/bim/module/ifcgit/operator.py index 5f01ef78ea..32b49b7e57 100644 --- a/src/bonsai/bonsai/bim/module/ifcgit/operator.py +++ b/src/bonsai/bonsai/bim/module/ifcgit/operator.py @@ -202,7 +202,7 @@ class DeleteTag(bpy.types.Operator): class RefreshGit(bpy.types.Operator): """Refresh revision list""" - bl_label = "" + bl_label = "Refresh" bl_idname = "ifcgit.refresh" bl_options = {"REGISTER"} @@ -225,7 +225,7 @@ class RefreshGit(bpy.types.Operator): class DisplayRevision(bpy.types.Operator): """Colourise objects by selected revision""" - bl_label = "" + bl_label = "Colourise Revision" bl_idname = "ifcgit.display_revision" bl_options = {"REGISTER"} @@ -260,7 +260,7 @@ class DisplayUncommitted(bpy.types.Operator): class SwitchRevision(bpy.types.Operator): """Switches the repository to the selected revision and reloads the IFC file""" - bl_label = "" + bl_label = "Switch Revision" bl_idname = "ifcgit.switch_revision" bl_options = {"REGISTER"} diff --git a/src/bonsai/bonsai/bim/module/ifcgit/ui.py b/src/bonsai/bonsai/bim/module/ifcgit/ui.py index 6ac4aceb74..1d03d37b74 100644 --- a/src/bonsai/bonsai/bim/module/ifcgit/ui.py +++ b/src/bonsai/bonsai/bim/module/ifcgit/ui.py @@ -113,14 +113,11 @@ class IFCGIT_PT_panel(bpy.types.Panel): else: row.label(text="Working branch: " + IfcGitData.data["active_branch_name"]) - grouped = layout.row() - column = grouped.column() - row = column.row() + row = layout.row() row.prop(props, "display_branch", text="Browse branch") row.prop(props, "ifcgit_filter", text="Filter revisions") - row = column.row() - row.template_list( + layout.template_list( "COMMIT_UL_List", "The_List", props, @@ -128,20 +125,13 @@ class IFCGIT_PT_panel(bpy.types.Panel): props, "commit_index", ) - column = grouped.column() - row = column.row() + + row = layout.row(align=True) row.operator("ifcgit.refresh", icon="FILE_REFRESH") - if not is_dirty: - - row = column.row() row.operator("ifcgit.display_revision", icon="SELECT_DIFFERENCE") - - row = column.row() row.operator("ifcgit.switch_revision", icon="CURRENT_FILE") - - row = column.row() - row.operator("ifcgit.merge", icon="EXPERIMENTAL", text="") + row.operator("ifcgit.merge", icon="SYSTEM") if not props.ifcgit_commits: return From d5b551874d9f96971aaf221888b037532a1bfbb9 Mon Sep 17 00:00:00 2001 From: Bruno Postle Date: Thu, 2 Apr 2026 23:16:40 +0100 Subject: [PATCH 04/16] ifcgit: pre-fill branch name when switching to a remote branch tip When a remote branch tip is checked out (resulting in detached HEAD), the new-branch name field is now pre-filled with the local equivalent of the remote branch name (generating a unique suffix if that name is already taken), so the commit button is immediately usable. See #7580 Generated with the assistance of an AI coding tool. --- src/bonsai/bonsai/tool/ifcgit.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/bonsai/bonsai/tool/ifcgit.py b/src/bonsai/bonsai/tool/ifcgit.py index 3cb9a7f227..7bfda6cc57 100644 --- a/src/bonsai/bonsai/tool/ifcgit.py +++ b/src/bonsai/bonsai/tool/ifcgit.py @@ -476,11 +476,28 @@ class IfcGit: if item.hexsha in lookup: for branch in lookup[item.hexsha]: if branch.name == props.display_branch: + if isinstance(branch, git.RemoteReference): + # Checking out a remote branch tip goes to detached HEAD. + # Pre-fill the new branch name field with the local equivalent + # so the user isn't blocked from committing without a hint. + local_name = branch.remote_head + props.new_branch_name = cls._unique_branch_name(repo, local_name) branch.checkout() return # NOTE this is calling the git binary in a subprocess repo.git.checkout(item.hexsha) + @classmethod + def _unique_branch_name(cls, repo: git.Repo, name: str) -> str: + """Return name if unused, otherwise name-2, name-3, etc.""" + existing = {h.name for h in repo.heads} + if name not in existing: + return name + i = 2 + while f"{name}-{i}" in existing: + i += 1 + return f"{name}-{i}" + @classmethod def delete_collection(cls, blender_collection: bpy.types.Collection) -> None: for obj in blender_collection.objects: From e3cadab40656b92e80b0af88bf83cfaae54cd081 Mon Sep 17 00:00:00 2001 From: Bruno Postle Date: Thu, 2 Apr 2026 23:42:28 +0100 Subject: [PATCH 05/16] ifcgit: add clone widget to new project wizard (#7579) --- src/bonsai/bonsai/bim/module/project/ui.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/bonsai/bonsai/bim/module/project/ui.py b/src/bonsai/bonsai/bim/module/project/ui.py index dfe8e44987..5e828ddd9b 100644 --- a/src/bonsai/bonsai/bim/module/project/ui.py +++ b/src/bonsai/bonsai/bim/module/project/ui.py @@ -19,6 +19,7 @@ from __future__ import annotations import os +import shutil from typing import TYPE_CHECKING import bpy @@ -384,6 +385,18 @@ class BIM_PT_new_project_wizard(Panel): row = self.layout.row() row.operator("bim.create_project") + if shutil.which("git"): + git_props = context.scene.IfcGitProperties + box = self.layout.box() + row = box.row() + row.label(text="Clone a remote Git repository") + row = box.row() + row.prop(git_props, "remote_url") + row = box.row() + row.prop(git_props, "local_folder") + row = box.row() + row.operator("ifcgit.clone_repo", icon="IMPORT") + class BIM_PT_project_library(Panel): bl_label = "Project Library" From c5210a4f8205116c9bf7f95d8927bef1b5ffea25 Mon Sep 17 00:00:00 2001 From: Bruno Postle Date: Thu, 2 Apr 2026 23:54:52 +0100 Subject: [PATCH 06/16] ifcgit: sync load_project post-import steps with project operator See #7578 --- src/bonsai/bonsai/tool/ifcgit.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/bonsai/bonsai/tool/ifcgit.py b/src/bonsai/bonsai/tool/ifcgit.py index 7bfda6cc57..1a69158918 100644 --- a/src/bonsai/bonsai/tool/ifcgit.py +++ b/src/bonsai/bonsai/tool/ifcgit.py @@ -278,6 +278,7 @@ class IfcGit: bpy.data.orphans_purge(do_recursive=True) # ty:ignore[unknown-argument] from bonsai.bim.module.root.data import IfcClassData + import bonsai.bim.handler IfcClassData.is_loaded = False @@ -285,10 +286,11 @@ class IfcGit: settings.should_setup_viewport_camera = False ifc_importer = import_ifc.IfcImporter(settings) ifc_importer.execute() - tool.Project.load_project_pset_templates() tool.Project.load_default_thumbnails() tool.Project.set_default_context() tool.Project.set_default_modeling_dimensions() + tool.Root.reload_grid_decorator() + bonsai.bim.handler.refresh_ui_data() bpy.ops.object.select_all(action="DESELECT") @classmethod From 3a6881ca17903576746e18ee004f187a002d6544 Mon Sep 17 00:00:00 2001 From: Bruno Postle Date: Fri, 3 Apr 2026 00:20:05 +0100 Subject: [PATCH 07/16] ifcgit: add rename branch button next to working branch label See #7577 Generated with the assistance of an AI coding tool. --- .../bonsai/bim/module/ifcgit/__init__.py | 1 + .../bonsai/bim/module/ifcgit/operator.py | 34 +++++++++++++++++++ src/bonsai/bonsai/bim/module/ifcgit/ui.py | 1 + src/bonsai/bonsai/core/ifcgit.py | 4 +++ src/bonsai/bonsai/tool/ifcgit.py | 4 +++ 5 files changed, 44 insertions(+) diff --git a/src/bonsai/bonsai/bim/module/ifcgit/__init__.py b/src/bonsai/bonsai/bim/module/ifcgit/__init__.py index df47b7774c..077a08b062 100644 --- a/src/bonsai/bonsai/bim/module/ifcgit/__init__.py +++ b/src/bonsai/bonsai/bim/module/ifcgit/__init__.py @@ -36,6 +36,7 @@ classes = ( operator.ObjectLog, operator.Push, operator.RefreshGit, + operator.RenameBranch, operator.SwitchRevision, operator.InstallGit, operator.RunGitDiff, diff --git a/src/bonsai/bonsai/bim/module/ifcgit/operator.py b/src/bonsai/bonsai/bim/module/ifcgit/operator.py index 32b49b7e57..8e86a5b18e 100644 --- a/src/bonsai/bonsai/bim/module/ifcgit/operator.py +++ b/src/bonsai/bonsai/bim/module/ifcgit/operator.py @@ -469,3 +469,37 @@ class RunGitDiff(bpy.types.Operator): def execute(self, context): core.run_git_diff(tool.IfcGit, self, self.save_to_temp) return {"FINISHED"} + + +class RenameBranch(bpy.types.Operator): + """Rename the current branch""" + + bl_label = "Rename Branch" + bl_idname = "ifcgit.rename_branch" + bl_options = {"REGISTER"} + + new_name: bpy.props.StringProperty(name="New name") # pyright: ignore[reportRedeclaration] + + if TYPE_CHECKING: + new_name: str + + @classmethod + def poll(cls, context): + IfcGitData.make_sure_is_loaded() + if not IfcGitData.data["repo"]: + return False + if IfcGitData.data["is_detached"]: + return False + if IfcGitData.data["is_dirty"]: + return False + return True + + def invoke(self, context, event): + self.new_name = IfcGitData.data["active_branch_name"] + return context.window_manager.invoke_props_dialog(self) + + def execute(self, context): + repo = IfcGitData.data["repo"] + core.rename_branch(tool.IfcGit, repo, self.new_name) + refresh() + return {"FINISHED"} diff --git a/src/bonsai/bonsai/bim/module/ifcgit/ui.py b/src/bonsai/bonsai/bim/module/ifcgit/ui.py index 1d03d37b74..3b8bc45000 100644 --- a/src/bonsai/bonsai/bim/module/ifcgit/ui.py +++ b/src/bonsai/bonsai/bim/module/ifcgit/ui.py @@ -112,6 +112,7 @@ class IFCGIT_PT_panel(bpy.types.Panel): row.label(text="Working branch: Detached HEAD") else: row.label(text="Working branch: " + IfcGitData.data["active_branch_name"]) + row.operator("ifcgit.rename_branch", icon="GREASEPENCIL", text="") row = layout.row() row.prop(props, "display_branch", text="Browse branch") diff --git a/src/bonsai/bonsai/core/ifcgit.py b/src/bonsai/bonsai/core/ifcgit.py index f14dda6cbb..3fb8a79055 100644 --- a/src/bonsai/bonsai/core/ifcgit.py +++ b/src/bonsai/bonsai/core/ifcgit.py @@ -87,6 +87,10 @@ def delete_remote(ifcgit: type[tool.IfcGit], repo: git.Repo, remote_name: str) - ifcgit.delete_remote(repo, remote_name) +def rename_branch(ifcgit: type[tool.IfcGit], repo: git.Repo, new_name: str) -> None: + ifcgit.rename_branch(repo, new_name) + + def push(ifcgit: type[tool.IfcGit], repo: git.Repo, remote_name: str, operator: bpy.types.Operator) -> None: error_message = ifcgit.push(repo, remote_name, ifcgit.get_active_branch_name()) if error_message: diff --git a/src/bonsai/bonsai/tool/ifcgit.py b/src/bonsai/bonsai/tool/ifcgit.py index 1a69158918..74cef0f522 100644 --- a/src/bonsai/bonsai/tool/ifcgit.py +++ b/src/bonsai/bonsai/tool/ifcgit.py @@ -155,6 +155,10 @@ class IfcGit: if tag_name in repo.tags: repo.delete_tag(tag_name) + @classmethod + def rename_branch(cls, repo: git.Repo, new_name: str) -> None: + repo.active_branch.rename(new_name) + @classmethod def add_remote(cls, repo: git.Repo, remote_name: str, remote_url: str) -> None: repo.create_remote(name=remote_name, url=remote_url) From ca6e9504968cd1097df4bccd7de12a49ba43faa7 Mon Sep 17 00:00:00 2001 From: Bruno Postle Date: Fri, 3 Apr 2026 13:27:29 +0100 Subject: [PATCH 08/16] ifcgit: conflict report panel and dry-run merge preview Parse ifcmerge JSON output and display a per-conflict breakdown in the panel when merge fails. Ctrl+click on the Merge button previews conflicts without committing. Add SelectConflictEntity operator to select and frame the conflicting object in the 3D viewport. Generated with the assistance of an AI coding tool. --- .../bonsai/bim/module/ifcgit/__init__.py | 1 + .../bonsai/bim/module/ifcgit/operator.py | 73 +++++++++- src/bonsai/bonsai/bim/module/ifcgit/prop.py | 6 + src/bonsai/bonsai/bim/module/ifcgit/ui.py | 51 +++++++ src/bonsai/bonsai/core/ifcgit.py | 48 ++++++- src/bonsai/bonsai/core/tool.py | 6 +- src/bonsai/bonsai/tool/ifcgit.py | 73 ++++++++-- src/bonsai/test/core/test_ifcgit.py | 61 ++++++++- src/bonsai/test/tool/test_ifcgit.py | 125 ++++++++++++++++++ 9 files changed, 427 insertions(+), 17 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/ifcgit/__init__.py b/src/bonsai/bonsai/bim/module/ifcgit/__init__.py index 077a08b062..6f4fec0836 100644 --- a/src/bonsai/bonsai/bim/module/ifcgit/__init__.py +++ b/src/bonsai/bonsai/bim/module/ifcgit/__init__.py @@ -34,6 +34,7 @@ classes = ( operator.Fetch, operator.Merge, operator.ObjectLog, + operator.SelectConflictEntity, operator.Push, operator.RefreshGit, operator.RenameBranch, diff --git a/src/bonsai/bonsai/bim/module/ifcgit/operator.py b/src/bonsai/bonsai/bim/module/ifcgit/operator.py index 8e86a5b18e..cc0f75577a 100644 --- a/src/bonsai/bonsai/bim/module/ifcgit/operator.py +++ b/src/bonsai/bonsai/bim/module/ifcgit/operator.py @@ -278,7 +278,7 @@ class SwitchRevision(bpy.types.Operator): class Merge(bpy.types.Operator): - """Merges the selected branch into working branch""" + """Merges the selected branch into working branch.\nCtrl+click to preview without merging""" bl_label = "Merge this branch" bl_idname = "ifcgit.merge" @@ -292,8 +292,14 @@ class Merge(bpy.types.Operator): return True return False - def execute(self, context): + def invoke(self, context, event): + if event.ctrl: + core.dry_run_merge(tool.IfcGit, tool.Ifc, self) + refresh() + return {"FINISHED"} + return self.execute(context) + def execute(self, context): if core.merge_branch(tool.IfcGit, tool.Ifc, self) is not False: refresh() return {"FINISHED"} @@ -301,6 +307,69 @@ class Merge(bpy.types.Operator): return {"CANCELLED"} +class SelectConflictEntity(bpy.types.Operator): + """Select the conflicting entity in the viewport""" + + bl_label = "Select Conflict Entity" + bl_idname = "ifcgit.select_conflict_entity" + bl_options = {"REGISTER"} + + step_id: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration] + + if TYPE_CHECKING: + step_id: int + + def execute(self, context): + model = tool.Ifc.get() + if not model: + return {"CANCELLED"} + + try: + entity = model.by_id(self.step_id) + except Exception: + self.report({"WARNING"}, f"Entity #{self.step_id} not found (may have been deleted locally)") + return {"CANCELLED"} + + obj = tool.Ifc.get_object(entity) + if obj is None: + # Walk inverse references up to 5 hops to find nearest entity with a Blender object + visited = {entity.id()} + queue = [entity] + for _ in range(5): + next_queue = [] + for ent in queue: + for inv in model.get_inverse(ent): + if inv.id() in visited: + continue + visited.add(inv.id()) + obj = tool.Ifc.get_object(inv) + if obj is not None: + break + next_queue.append(inv) + if obj is not None: + break + if obj is not None: + break + queue = next_queue + + if obj is None: + self.report({"INFO"}, f"No viewport representation found for #{self.step_id} ({entity.is_a()})") + return {"CANCELLED"} + + bpy.ops.object.select_all(action="DESELECT") + obj.select_set(True) + context.view_layer.objects.active = obj + for area in context.screen.areas: + if area.type == "VIEW_3D": + region = next((r for r in area.regions if r.type == "WINDOW"), None) + if region: + with context.temp_override(area=area, region=region): + bpy.ops.view3d.view_selected() + break + + return {"FINISHED"} + + class Push(bpy.types.Operator): """Pushes the working branch to selected remote""" diff --git a/src/bonsai/bonsai/bim/module/ifcgit/prop.py b/src/bonsai/bonsai/bim/module/ifcgit/prop.py index 865cb4eacf..494da26f35 100644 --- a/src/bonsai/bonsai/bim/module/ifcgit/prop.py +++ b/src/bonsai/bonsai/bim/module/ifcgit/prop.py @@ -139,6 +139,11 @@ class IfcGitProperties(PropertyGroup): ], update=update_revlist, ) + merge_conflicts: StringProperty( + name="Merge Conflicts", + description="JSON report from last failed merge attempt", + default="", + ) if TYPE_CHECKING: ifcgit_commits: bpy.types.bpy_prop_collection_idprop[IfcGitListItem] @@ -153,3 +158,4 @@ class IfcGitProperties(PropertyGroup): display_branch: str select_remote: str ifcgit_filter: Literal["all", "tagged", "relevant"] + merge_conflicts: str diff --git a/src/bonsai/bonsai/bim/module/ifcgit/ui.py b/src/bonsai/bonsai/bim/module/ifcgit/ui.py index 3b8bc45000..901dee31ad 100644 --- a/src/bonsai/bonsai/bim/module/ifcgit/ui.py +++ b/src/bonsai/bonsai/bim/module/ifcgit/ui.py @@ -134,6 +134,57 @@ class IFCGIT_PT_panel(bpy.types.Panel): row.operator("ifcgit.switch_revision", icon="CURRENT_FILE") row.operator("ifcgit.merge", icon="SYSTEM") + conflicts = tool.IfcGit.get_merge_conflicts() + if conflicts is not None: + box = layout.box() + box.alert = True + row = box.row() + row.label( + text=f"Merge failed \u2014 {len(conflicts)} conflict(s)", + icon="ERROR", + ) + for conflict in conflicts: + col = box.column(align=True) + conflict_type = conflict.get("type", "") + entity_id = conflict.get("entity_id", "?") + local_id = conflict.get("original_local_id") + + if conflict_type == "attribute_conflict": + entity_class = conflict.get("entity_class", "Entity") + attr_idx = conflict.get("attribute_index", "?") + desc = f"#{entity_id} {entity_class}: attribute {attr_idx} conflict" + elif conflict_type == "entity_deleted_and_modified": + entity_class = conflict.get("entity_class", "Entity") + desc = f"#{entity_id} {entity_class}: " + conflict.get("message", "deleted/modified conflict") + elif conflict_type == "class_changed": + desc = ( + f"#{entity_id}: class changed " + + conflict.get("base_class", "?") + + " \u2192 " + + conflict.get("modified_class", "?") + ) + elif conflict_type == "required_entity_deleted": + desc = f"#{entity_id}: " + conflict.get("message", "required entity deleted") + else: + desc = f"#{entity_id}: {conflict_type}" + + row = col.row(align=True) + row.label(text=desc) + if local_id: + op = row.operator( + "ifcgit.select_conflict_entity", + text="", + icon="RESTRICT_SELECT_OFF", + ) + op.step_id = local_id + + if conflict_type == "attribute_conflict": + sub = col.column(align=True) + sub.scale_y = 0.75 + sub.label(text=f" Base: {conflict.get('base_value', '')}") + sub.label(text=f" Local: {conflict.get('local_value', '')}") + sub.label(text=f" Remote: {conflict.get('remote_value', '')}") + if not props.ifcgit_commits: return diff --git a/src/bonsai/bonsai/core/ifcgit.py b/src/bonsai/bonsai/core/ifcgit.py index 3fb8a79055..289337908a 100644 --- a/src/bonsai/bonsai/core/ifcgit.py +++ b/src/bonsai/bonsai/core/ifcgit.py @@ -98,6 +98,7 @@ def push(ifcgit: type[tool.IfcGit], repo: git.Repo, remote_name: str, operator: def refresh_revision_list(ifcgit: type[tool.IfcGit], ifc: type[tool.Ifc]) -> None: + ifcgit.clear_merge_conflicts() if ifcgit.repo_has_commits(): ifcgit.refresh_revision_list(ifc.get_path()) @@ -147,19 +148,60 @@ def merge_branch(ifcgit: type[tool.IfcGit], ifc: type[tool.Ifc], operator: bpy.t operator.report({"ERROR"}, "Unknown IFC Merge failure") return False elif merge_result == "conflict": - error = ifcgit.git_mergetool(mergetool) - if error: + conflicts = ifcgit.git_mergetool(mergetool, path_ifc) + if conflicts is not None: ifcgit.git_merge_abort() - operator.report({"ERROR"}, "IFC Merge failed:" + error) + ifcgit.store_merge_conflicts(conflicts) + operator.report({"WARNING"}, "Merge failed — see the conflict report in the panel below") return False ifcgit.commit_merge(path_ifc) + ifcgit.clear_merge_conflicts() ifcgit.set_display_branch() + ifcgit.git_checkout(path_ifc) ifcgit.load_project(path_ifc) ifcgit.refresh_revision_list(path_ifc) ifcgit.decolourise() +def dry_run_merge(ifcgit: type[tool.IfcGit], ifc: type[tool.Ifc], operator: bpy.types.Operator) -> None: + path_ifc = ifc.get_path() + ifcgit.config_ifcmerge() + + branch_name = ifcgit.get_selected_branch() + if branch_name is None: + return + + mergetool = ifcgit.get_merge_tool(branch_name) + merge_result = ifcgit.git_merge_no_commit(branch_name) + + if merge_result == "error": + try: + ifcgit.git_merge_abort() + except Exception: + pass + operator.report({"ERROR"}, "Unknown IFC Merge failure") + return + + if merge_result == "conflict": + conflicts = ifcgit.git_mergetool(mergetool, path_ifc) + ifcgit.git_merge_abort() + if conflicts is not None: + ifcgit.store_merge_conflicts(conflicts) + operator.report({"WARNING"}, "Merge preview: conflicts found — see the panel below") + else: + ifcgit.clear_merge_conflicts() + operator.report({"INFO"}, "Merge preview: no conflicts") + else: + # Clean merge or already up to date — abort the pending merge state if any + try: + ifcgit.git_merge_abort() + except Exception: + pass + ifcgit.clear_merge_conflicts() + operator.report({"INFO"}, "Merge preview: no conflicts") + + def entity_log(ifcgit: type[tool.IfcGit], ifc: type[tool.Ifc], step_id: int, operator: bpy.types.Operator) -> None: path_ifc = ifc.get_path() log_text = ifcgit.entity_log(path_ifc, step_id) diff --git a/src/bonsai/bonsai/core/tool.py b/src/bonsai/bonsai/core/tool.py index 910ed79627..e6a60c9deb 100644 --- a/src/bonsai/bonsai/core/tool.py +++ b/src/bonsai/bonsai/core/tool.py @@ -559,7 +559,11 @@ class IfcGit: def get_selected_branch(cls): pass def git_merge(cls, branch_name): pass def git_merge_abort(cls): pass - def git_mergetool(cls, mergetool): pass + def git_merge_no_commit(cls, branch_name): pass + def git_mergetool(cls, mergetool, path_ifc): pass + def store_merge_conflicts(cls, conflicts): pass + def clear_merge_conflicts(cls): pass + def get_merge_conflicts(cls): pass def set_display_branch(cls): pass def get_active_branch_name(cls): pass def get_ifcgit_props(cls): pass diff --git a/src/bonsai/bonsai/tool/ifcgit.py b/src/bonsai/bonsai/tool/ifcgit.py index 74cef0f522..ae7176eb6f 100644 --- a/src/bonsai/bonsai/tool/ifcgit.py +++ b/src/bonsai/bonsai/tool/ifcgit.py @@ -18,6 +18,7 @@ from __future__ import annotations +import json import logging import os import re @@ -282,8 +283,11 @@ class IfcGit: bpy.data.orphans_purge(do_recursive=True) # ty:ignore[unknown-argument] from bonsai.bim.module.root.data import IfcClassData + from bonsai.bim.module.model.data import AuthoringData import bonsai.bim.handler + AuthoringData.type_thumbnails = {} + IfcClassData.is_loaded = False settings = import_ifc.IfcImportSettings.factory(bpy.context, path_ifc, logging.getLogger("ImportIFC")) @@ -514,20 +518,25 @@ class IfcGit: def config_ifcmerge(cls) -> None: config_reader = IfcGitRepo.repo.config_reader() section = 'mergetool "ifcmerge"' + new_cmd = "ifcmerge $BASE $LOCAL $REMOTE $MERGED > $MERGED.ifcmerge" if not config_reader.has_section(section): with IfcGitRepo.repo.config_writer() as config_writer: - config_writer.set_value(section, "cmd", "ifcmerge $BASE $LOCAL $REMOTE $MERGED") + config_writer.set_value(section, "cmd", new_cmd) + config_writer.set_value(section, "trustExitCode", True) + elif config_reader.get_value(section, "cmd") != new_cmd: + with IfcGitRepo.repo.config_writer() as config_writer: + config_writer.set_value(section, "cmd", new_cmd) config_writer.set_value(section, "trustExitCode", True) section = 'mergetool "ifcmerge-forward"' - new_cmd = "ifcmerge --prioritise-local $BASE $LOCAL $REMOTE $MERGED" - old_cmd = "ifcmerge $BASE $REMOTE $LOCAL $MERGED" + new_cmd = "ifcmerge --prioritise-local $BASE $LOCAL $REMOTE $MERGED > $MERGED.ifcmerge" if not config_reader.has_section(section): with IfcGitRepo.repo.config_writer() as config_writer: config_writer.set_value(section, "cmd", new_cmd) config_writer.set_value(section, "trustExitCode", True) - elif config_reader.get_value(section, "cmd") == old_cmd: + elif config_reader.get_value(section, "cmd") != new_cmd: with IfcGitRepo.repo.config_writer() as config_writer: config_writer.set_value(section, "cmd", new_cmd) + config_writer.set_value(section, "trustExitCode", True) @classmethod def config_push(cls, repo: git.Repo) -> None: @@ -590,14 +599,62 @@ class IfcGit: return "error" @classmethod - def git_mergetool(cls, mergetool: str) -> Union[str, None]: - """Run ifcmerge tool. Returns None on success, error message string on failure.""" + def git_merge_no_commit(cls, branch_name: str) -> Union[str, None]: + """Attempt a git merge without committing (always leaves a merge state to abort). + Returns None on clean merge, 'conflict' on conflict, or 'error' on unknown failure.""" repo = IfcGitRepo.repo + branch = repo.branches[branch_name] + try: + repo.git.merge(branch, no_commit=True, no_ff=True) + return None + except git.exc.GitCommandError: + return "conflict" + except git.exc.GitError: + return "error" + + @classmethod + def git_mergetool(cls, mergetool: str, path_ifc: str) -> Union[list, None]: + """Run ifcmerge tool. Returns None on success, list of conflict dicts on failure.""" + repo = IfcGitRepo.repo + report_path = path_ifc + ".ifcmerge" try: repo.git.mergetool(tool=mergetool) + except git.exc.GitCommandError: + pass + + conflicts = None + if os.path.exists(report_path): + try: + with open(report_path) as f: + content = f.read().strip() + if content: + data = json.loads(content) + conflicts = data.get("conflicts", []) + except (json.JSONDecodeError, OSError): + pass + try: + os.remove(report_path) + except OSError: + pass + return conflicts + + @classmethod + def store_merge_conflicts(cls, conflicts: list) -> None: + cls.get_ifcgit_props().merge_conflicts = json.dumps(conflicts) + + @classmethod + def clear_merge_conflicts(cls) -> None: + cls.get_ifcgit_props().merge_conflicts = "" + + @classmethod + def get_merge_conflicts(cls) -> Union[list, None]: + raw = cls.get_ifcgit_props().merge_conflicts + if not raw: + return None + try: + return json.loads(raw) + except json.JSONDecodeError: return None - except git.exc.GitCommandError as exc: - return re.sub("( stdout: '|')", "", exc.stdout) @classmethod def git_merge_abort(cls) -> None: diff --git a/src/bonsai/test/core/test_ifcgit.py b/src/bonsai/test/core/test_ifcgit.py index b4d80985b2..539e4a082c 100644 --- a/src/bonsai/test/core/test_ifcgit.py +++ b/src/bonsai/test/core/test_ifcgit.py @@ -132,12 +132,14 @@ class TestPush: class TestRefreshRevisionList: def test_refreshes_when_repo_has_heads(self, ifcgit, ifc): + ifcgit.clear_merge_conflicts().should_be_called() ifcgit.repo_has_commits().should_be_called().will_return(True) ifc.get_path().should_be_called().will_return("path/to/model.ifc") ifcgit.refresh_revision_list("path/to/model.ifc").should_be_called() subject.refresh_revision_list(ifcgit, ifc) def test_skips_when_repo_has_no_heads(self, ifcgit, ifc): + ifcgit.clear_merge_conflicts().should_be_called() ifcgit.repo_has_commits().should_be_called().will_return(False) subject.refresh_revision_list(ifcgit, ifc) # nothing else should be called — Prophecy will verify @@ -194,7 +196,9 @@ class TestMergeBranch: ifcgit.get_selected_branch().should_be_called().will_return("feature") ifcgit.get_merge_tool("feature").should_be_called().will_return("ifcmerge-forward") ifcgit.git_merge("feature").should_be_called().will_return(None) + ifcgit.clear_merge_conflicts().should_be_called() ifcgit.set_display_branch().should_be_called() + ifcgit.git_checkout("path/to/model.ifc").should_be_called() ifcgit.load_project("path/to/model.ifc").should_be_called() ifcgit.refresh_revision_list("path/to/model.ifc").should_be_called() ifcgit.decolourise().should_be_called() @@ -206,25 +210,29 @@ class TestMergeBranch: ifcgit.get_selected_branch().should_be_called().will_return("feature") ifcgit.get_merge_tool("feature").should_be_called().will_return("ifcmerge-forward") ifcgit.git_merge("feature").should_be_called().will_return("conflict") - ifcgit.git_mergetool("ifcmerge-forward").should_be_called().will_return(None) + ifcgit.git_mergetool("ifcmerge-forward", "path/to/model.ifc").should_be_called().will_return(None) ifcgit.commit_merge("path/to/model.ifc").should_be_called() + ifcgit.clear_merge_conflicts().should_be_called() ifcgit.set_display_branch().should_be_called() + ifcgit.git_checkout("path/to/model.ifc").should_be_called() ifcgit.load_project("path/to/model.ifc").should_be_called() ifcgit.refresh_revision_list("path/to/model.ifc").should_be_called() ifcgit.decolourise().should_be_called() subject.merge_branch(ifcgit, ifc, operator=None) def test_conflict_mergetool_failure(self, ifcgit, ifc): + conflicts = [{"type": "attribute_conflict", "entity_id": 42}] ifc.get_path().should_be_called().will_return("path/to/model.ifc") ifcgit.config_ifcmerge().should_be_called() ifcgit.get_selected_branch().should_be_called().will_return("feature") ifcgit.get_merge_tool("feature").should_be_called().will_return("ifcmerge-forward") ifcgit.git_merge("feature").should_be_called().will_return("conflict") - ifcgit.git_mergetool("ifcmerge-forward").should_be_called().will_return("merge error") + ifcgit.git_mergetool("ifcmerge-forward", "path/to/model.ifc").should_be_called().will_return(conflicts) ifcgit.git_merge_abort().should_be_called() + ifcgit.store_merge_conflicts(conflicts).should_be_called() op = MockOperator() subject.merge_branch(ifcgit, ifc, op) - assert op.reports == [({"ERROR"}, "IFC Merge failed:merge error")] + assert op.reports == [({"WARNING"}, "Merge failed — see the conflict report in the panel below")] def test_unknown_merge_error(self, ifcgit, ifc): ifc.get_path().should_be_called().will_return("path/to/model.ifc") @@ -237,6 +245,53 @@ class TestMergeBranch: assert op.reports == [({"ERROR"}, "Unknown IFC Merge failure")] +class TestDryRunMerge: + def test_no_branch_at_selected_commit(self, ifcgit, ifc): + ifc.get_path().should_be_called().will_return("path/to/model.ifc") + ifcgit.config_ifcmerge().should_be_called() + ifcgit.get_selected_branch().should_be_called().will_return(None) + subject.dry_run_merge(ifcgit, ifc, operator=None) + + def test_clean_merge_preview(self, ifcgit, ifc): + ifc.get_path().should_be_called().will_return("path/to/model.ifc") + ifcgit.config_ifcmerge().should_be_called() + ifcgit.get_selected_branch().should_be_called().will_return("feature") + ifcgit.get_merge_tool("feature").should_be_called().will_return("ifcmerge-forward") + ifcgit.git_merge_no_commit("feature").should_be_called().will_return(None) + ifcgit.git_merge_abort().should_be_called() + ifcgit.clear_merge_conflicts().should_be_called() + op = MockOperator() + subject.dry_run_merge(ifcgit, ifc, op) + assert op.reports == [({"INFO"}, "Merge preview: no conflicts")] + + def test_conflict_preview_shows_report(self, ifcgit, ifc): + conflicts = [{"type": "attribute_conflict", "entity_id": 42}] + ifc.get_path().should_be_called().will_return("path/to/model.ifc") + ifcgit.config_ifcmerge().should_be_called() + ifcgit.get_selected_branch().should_be_called().will_return("feature") + ifcgit.get_merge_tool("feature").should_be_called().will_return("ifcmerge-forward") + ifcgit.git_merge_no_commit("feature").should_be_called().will_return("conflict") + ifcgit.git_mergetool("ifcmerge-forward", "path/to/model.ifc").should_be_called().will_return(conflicts) + ifcgit.git_merge_abort().should_be_called() + ifcgit.store_merge_conflicts(conflicts).should_be_called() + op = MockOperator() + subject.dry_run_merge(ifcgit, ifc, op) + assert op.reports == [({"WARNING"}, "Merge preview: conflicts found — see the panel below")] + + def test_conflict_preview_mergetool_succeeds(self, ifcgit, ifc): + ifc.get_path().should_be_called().will_return("path/to/model.ifc") + ifcgit.config_ifcmerge().should_be_called() + ifcgit.get_selected_branch().should_be_called().will_return("feature") + ifcgit.get_merge_tool("feature").should_be_called().will_return("ifcmerge-forward") + ifcgit.git_merge_no_commit("feature").should_be_called().will_return("conflict") + ifcgit.git_mergetool("ifcmerge-forward", "path/to/model.ifc").should_be_called().will_return(None) + ifcgit.git_merge_abort().should_be_called() + ifcgit.clear_merge_conflicts().should_be_called() + op = MockOperator() + subject.dry_run_merge(ifcgit, ifc, op) + assert op.reports == [({"INFO"}, "Merge preview: no conflicts")] + + class TestEntityLog: def test_run(self, ifcgit, ifc): ifc.get_path().should_be_called().will_return("path/to/model.ifc") diff --git a/src/bonsai/test/tool/test_ifcgit.py b/src/bonsai/test/tool/test_ifcgit.py index 5cd051aa5f..964bd96fd2 100644 --- a/src/bonsai/test/tool/test_ifcgit.py +++ b/src/bonsai/test/tool/test_ifcgit.py @@ -454,3 +454,128 @@ class TestIfcDiffIds(NewFile): result = IfcGit.ifc_diff_ids(repo, sha_a, sha_b, ifc_path) assert 1 in result["modified"] assert 2 in result["modified"] + + +# --------------------------------------------------------------------------- +# Merge conflict report — store / clear / get +# --------------------------------------------------------------------------- + + +class TestStoreClearGetMergeConflicts(NewFile): + def test_round_trip(self): + conflicts = [{"type": "attribute_conflict", "entity_id": 42}] + IfcGit.store_merge_conflicts(conflicts) + result = IfcGit.get_merge_conflicts() + assert result == conflicts + + def test_get_returns_none_when_empty(self): + IfcGit.clear_merge_conflicts() + assert IfcGit.get_merge_conflicts() is None + + def test_clear_removes_stored_conflicts(self): + IfcGit.store_merge_conflicts([{"type": "class_changed"}]) + IfcGit.clear_merge_conflicts() + assert IfcGit.get_merge_conflicts() is None + + def test_get_returns_none_on_corrupt_json(self): + import bpy + + bpy.context.scene.IfcGitProperties.merge_conflicts = "not valid json {" + assert IfcGit.get_merge_conflicts() is None + + +# --------------------------------------------------------------------------- +# git_mergetool — report file reading +# --------------------------------------------------------------------------- + + +class TestGitMergetool: + @requires_git + def test_returns_none_when_report_file_absent(self): + import unittest.mock as mock + + with tempfile.TemporaryDirectory() as tmpdir: + ifc_path = os.path.join(tmpdir, "model.ifc") + mock_repo = mock.MagicMock() + IfcGitRepo.repo = mock_repo + result = IfcGit.git_mergetool("ifcmerge", ifc_path) + assert result is None + IfcGitRepo.repo = None + + @requires_git + def test_returns_none_when_report_file_empty(self): + import unittest.mock as mock + + with tempfile.TemporaryDirectory() as tmpdir: + ifc_path = os.path.join(tmpdir, "model.ifc") + report_path = ifc_path + ".ifcmerge" + open(report_path, "w").close() + mock_repo = mock.MagicMock() + IfcGitRepo.repo = mock_repo + result = IfcGit.git_mergetool("ifcmerge", ifc_path) + assert result is None + assert not os.path.exists(report_path) + IfcGitRepo.repo = None + + @requires_git + def test_parses_conflict_report_and_deletes_file(self): + import json + import unittest.mock as mock + + with tempfile.TemporaryDirectory() as tmpdir: + ifc_path = os.path.join(tmpdir, "model.ifc") + report_path = ifc_path + ".ifcmerge" + conflicts = [{"type": "attribute_conflict", "entity_id": 5}] + with open(report_path, "w") as f: + json.dump({"status": "failed", "conflicts": conflicts}, f) + mock_repo = mock.MagicMock() + mock_repo.git.mergetool.side_effect = git.exc.GitCommandError("mergetool", 1) + IfcGitRepo.repo = mock_repo + result = IfcGit.git_mergetool("ifcmerge", ifc_path) + assert result == conflicts + assert not os.path.exists(report_path) + IfcGitRepo.repo = None + + +# --------------------------------------------------------------------------- +# config_ifcmerge — cmd format and update +# --------------------------------------------------------------------------- + + +class TestConfigIfcmerge: + @requires_git + def test_writes_redirect_cmd_on_first_call(self): + with tempfile.TemporaryDirectory() as tmpdir: + repo = _make_repo(tmpdir) + IfcGitRepo.repo = repo + IfcGit.config_ifcmerge() + reader = repo.config_reader() + cmd = reader.get_value('mergetool "ifcmerge"', "cmd") + assert "> $MERGED.ifcmerge" in cmd + IfcGitRepo.repo = None + + @requires_git + def test_updates_cmd_missing_redirect(self): + with tempfile.TemporaryDirectory() as tmpdir: + repo = _make_repo(tmpdir) + IfcGitRepo.repo = repo + with repo.config_writer() as w: + w.set_value('mergetool "ifcmerge"', "cmd", "ifcmerge $BASE $LOCAL $REMOTE $MERGED") + w.set_value('mergetool "ifcmerge"', "trustExitCode", True) + IfcGit.config_ifcmerge() + reader = repo.config_reader() + cmd = reader.get_value('mergetool "ifcmerge"', "cmd") + assert "> $MERGED.ifcmerge" in cmd + IfcGitRepo.repo = None + + @requires_git + def test_forward_tool_writes_redirect_cmd(self): + with tempfile.TemporaryDirectory() as tmpdir: + repo = _make_repo(tmpdir) + IfcGitRepo.repo = repo + IfcGit.config_ifcmerge() + reader = repo.config_reader() + cmd = reader.get_value('mergetool "ifcmerge-forward"', "cmd") + assert "--prioritise-local" in cmd + assert "> $MERGED.ifcmerge" in cmd + IfcGitRepo.repo = None From 0a1c54cedc55c254be4bfb8bee251649bf573e09 Mon Sep 17 00:00:00 2001 From: Stephen Boddy Date: Sat, 4 Apr 2026 03:56:08 +0100 Subject: [PATCH 09/16] Fix the ci-bonsai-daily blender url --- .github/workflows/ci-bonsai-daily.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci-bonsai-daily.yml b/.github/workflows/ci-bonsai-daily.yml index 68b6ac2dbb..c6deec140e 100644 --- a/.github/workflows/ci-bonsai-daily.yml +++ b/.github/workflows/ci-bonsai-daily.yml @@ -109,7 +109,7 @@ jobs: # Ensure Bonsai and ifcsverchok enable/disable works before uploading to extensions repo. # Download Blender. - wget -q -O blender.tar.xz https://download.blender.org/release/Blender5.0/blender-5.1.0-linux-x64.tar.xz + wget -q -O blender.tar.xz https://download.blender.org/release/Blender5.1/blender-5.1.0-linux-x64.tar.xz tar -xf blender.tar.xz # Setup Blender. From 12123baafecd4e5f3404501c916882813eec949b Mon Sep 17 00:00:00 2001 From: Stephen Boddy Date: Sat, 4 Apr 2026 04:22:12 +0100 Subject: [PATCH 10/16] Update the pyver as 3.13 is default in 5.1 now --- .github/workflows/ci-bonsai-daily.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci-bonsai-daily.yml b/.github/workflows/ci-bonsai-daily.yml index c6deec140e..5e1e90e8d9 100644 --- a/.github/workflows/ci-bonsai-daily.yml +++ b/.github/workflows/ci-bonsai-daily.yml @@ -122,7 +122,7 @@ jobs: pip install -r requirements.txt python setup_extensions_repo.py --last-tag cd .. - bonsai_zip="$(pwd)/$(ls bonsai_unstable_repo/bonsai_py311*-linux-x64.zip)" + bonsai_zip="$(pwd)/$(ls bonsai_unstable_repo/bonsai_py313*-linux-x64.zip)" # Install Bonsai. blender --command extension install-file -r user_default -e $bonsai_zip From 5b1ec85f7526468aeb0deca54b581a008294c9d9 Mon Sep 17 00:00:00 2001 From: DesertSpringsCivil Date: Fri, 3 Apr 2026 15:47:13 -0600 Subject: [PATCH 11/16] feat: Reduce token usage in ifcchat and default to IFC4X3 - Add Anthropic prompt caching (cache_control on system prompt and tools) to reduce repeated token costs by ~90% - Truncate large tool results in conversation history (2000 char cap) to prevent context bloat from ifc_tree/ifc_select responses - Add sliding window (40 messages) on conversation history, trimming at user message boundaries to avoid breaking tool-call sequences - Default "New IFC" button to IFC4X3 schema instead of IFC4 - Constrain ifc_new schema parameter with enum to prevent invalid schema strings like "IFC4X3ADD2" Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ifcchat/api_anthropic.js | 11 +++++++++-- src/ifcchat/app.js | 33 +++++++++++++++++++++++++++++---- 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/src/ifcchat/api_anthropic.js b/src/ifcchat/api_anthropic.js index 0359cde936..6403317154 100644 --- a/src/ifcchat/api_anthropic.js +++ b/src/ifcchat/api_anthropic.js @@ -146,15 +146,22 @@ function toChatCompletionResponse(response) { export async function chat({ apiKey, model, messages, tools }) { const request = splitSystemAndMessages(messages); + const anthropicTools = toAnthropicTools(tools); + + // Mark the last tool with cache_control so the entire tool list is cached + if (anthropicTools.length > 0) { + anthropicTools[anthropicTools.length - 1].cache_control = { type: "ephemeral" }; + } + const body = { model, max_tokens: 4096, messages: request.messages, - tools: toAnthropicTools(tools), + tools: anthropicTools, }; if (request.system) { - body.system = request.system; + body.system = [{ type: "text", text: request.system, cache_control: { type: "ephemeral" } }]; } const res = await fetch("https://api.anthropic.com/v1/messages", { diff --git a/src/ifcchat/app.js b/src/ifcchat/app.js index b268bf8256..a3b340bc97 100644 --- a/src/ifcchat/app.js +++ b/src/ifcchat/app.js @@ -418,8 +418,8 @@ function callWorker(type, payload = {}) { // ---- Tool schemas (should match ifcmcp.core openai_tools()) ---- const tools = [ { - type: "function", function: { name: "ifc_new", description: "Create a new empty IFC model in memory.", - parameters: { type: "object", properties: { schema: { type: "string" } }, required: [], additionalProperties: false } } + type: "function", function: { name: "ifc_new", description: "Create a new empty IFC model in memory. Valid schemas: IFC4, IFC2X3, IFC4X3 (for IFC 4.3).", + parameters: { type: "object", properties: { schema: { type: "string", enum: ["IFC4", "IFC2X3", "IFC4X3"] } }, required: [], additionalProperties: false } } }, { type: "function", function: { name: "ifc_summary", description: "Get a concise overview of the loaded IFC model.", @@ -497,6 +497,27 @@ Be concise. Avoid dumping huge trees unless asked. let messages = []; // running conversation state (Chat Completions style) +const MAX_TOOL_RESULT_CHARS = 2000; +const MAX_HISTORY_MESSAGES = 40; + +function truncateToolResult(text) { + if (text.length <= MAX_TOOL_RESULT_CHARS) return text; + return text.slice(0, MAX_TOOL_RESULT_CHARS) + "\n... (truncated)"; +} + +function trimHistory() { + if (messages.length <= MAX_HISTORY_MESSAGES) return; + // Find a safe cut point — don't break mid-tool-call sequence. + // Walk forward from the trim target to find a user message boundary. + let cut = messages.length - MAX_HISTORY_MESSAGES; + while (cut < messages.length && messages[cut].role !== "user") { + cut++; + } + if (cut > 0 && cut < messages.length) { + messages.splice(0, cut); + } +} + async function runAgentTurn(userText) { const apiKey = apiKeyEl.value.trim(); if (!apiKey) throw new Error("Missing API key"); @@ -506,6 +527,7 @@ async function runAgentTurn(userText) { const baseURL = provider.baseUrlDefault ? baseUrlEl.value.trim() : undefined; messages.push({ role: "user", content: userText }); + trimHistory(); for (let i = 0; i < 64; i++) { const response = await chat({ @@ -535,12 +557,15 @@ async function runAgentTurn(userText) { const toolRes = await callWorker("toolCall", { name: call.function.name, args }); + const fullResult = JSON.stringify(toolRes.result); + messages.push({ role: "tool", tool_call_id: call.id, - content: JSON.stringify(toolRes.result), + content: truncateToolResult(fullResult), }); + // Show full result in UI, but only truncated version goes to the LLM addMessage("tool", `← ${call.function.name}: ${JSON.stringify(toolRes.result, null, 2)}`); } } @@ -588,7 +613,7 @@ ifcFileEl.onchange = async () => { newBtn.onclick = async () => { try { setBusy(true, "Creating new model…"); - const r = await callWorker("toolCall", { name: "ifc_new", args: { schema: "IFC4" } }); + const r = await callWorker("toolCall", { name: "ifc_new", args: { schema: "IFC4X3" } }); addMessage("assistant", `New model: ${JSON.stringify(r.result)}`); setBusy(false, "Ready"); } catch (e) { From 30517770e0967366b7342eebf86dcf58bc28a6fa Mon Sep 17 00:00:00 2001 From: Thomas Krijnen Date: Sat, 4 Apr 2026 13:12:47 +0200 Subject: [PATCH 12/16] Revert default tool output truncation --- src/ifcchat/app.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ifcchat/app.js b/src/ifcchat/app.js index a3b340bc97..203d25798a 100644 --- a/src/ifcchat/app.js +++ b/src/ifcchat/app.js @@ -497,11 +497,11 @@ Be concise. Avoid dumping huge trees unless asked. let messages = []; // running conversation state (Chat Completions style) -const MAX_TOOL_RESULT_CHARS = 2000; +const MAX_TOOL_RESULT_CHARS = 0; const MAX_HISTORY_MESSAGES = 40; function truncateToolResult(text) { - if (text.length <= MAX_TOOL_RESULT_CHARS) return text; + if (MAX_TOOL_RESULT_CHARS == 0 || text.length <= MAX_TOOL_RESULT_CHARS) return text; return text.slice(0, MAX_TOOL_RESULT_CHARS) + "\n... (truncated)"; } From 97ee4eaef0b576a6d262be60254aeacb90968d7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Perdig=C3=A3o?= Date: Sat, 4 Apr 2026 14:29:54 -0300 Subject: [PATCH 13/16] See #7888 - Fix snap when object changes during modal operator. Handle cases where the snapped target is modified while a modal operator is active (e.g., adding a door or window that alters the wall geometry). --- src/bonsai/bonsai/tool/raycast.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/bonsai/bonsai/tool/raycast.py b/src/bonsai/bonsai/tool/raycast.py index 6b613aecaf..38882a5095 100644 --- a/src/bonsai/bonsai/tool/raycast.py +++ b/src/bonsai/bonsai/tool/raycast.py @@ -889,8 +889,19 @@ class Raycast(bonsai.core.tool.Raycast): def create_snap_obj(cls, obj): if obj.data is None or not isinstance(obj.data, bpy.types.Mesh): return None - for snap_obj in cls.snap_objs: + for i, snap_obj in enumerate(cls.snap_objs): if obj.name == snap_obj.obj.name: + # Handle objects modified while a modal operator is active. + # Example: adding a door or window alters the wall geometry. + if len(obj.data.vertices) != len(snap_obj.verts_3d): + cls.snap_objs.pop(i) + snap_obj = SnapObj(obj) + cls.snap_objs.append(snap_obj) + for v1, v2 in zip(obj.data.vertices, snap_obj.verts_3d): + if (obj.matrix_world @ v1.co) != v2: + cls.snap_objs.pop(i) + snap_obj = SnapObj(obj) + cls.snap_objs.append(snap_obj) return snap_obj snap_obj = SnapObj(obj) cls.snap_objs.append(snap_obj) From 1c7e134d78ebf6bd67bc7fdf8f1de07c0bc66978 Mon Sep 17 00:00:00 2001 From: Ryan Schultz Date: Sat, 4 Apr 2026 13:35:41 -0500 Subject: [PATCH 14/16] Fix #3742: Remove coplanar boundary lines between adjacent same-material elements in Bonsai SVG drawings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `remove_coplanar_boundary_lines()` to operator.py (Bonsai uses this path, not draw.py's main()). After `merge_linework_and_add_metadata()` assigns material CSS classes, this post-processes the SVG to delete projection line segments that appear in two or more adjacent, coplanar elements with the same material and presentation style. Key design decisions: - Material identity: compared via sorted IFC material ID tuples from `get_materials()`, not CSS class names — avoids false matches between unrelated `material-null` elements. - Presentation style identity: compared via IFC IfcPresentationStyle IDs from `StyledByItem` on geometry representation items — handles elements with no material but distinct visual styles. - Physical adjacency: confirmed by a 3D shared-vertex test (tol=0.01 m) after a quick AABB guard, rejecting elements whose 2D projections overlap but sit at different depths. - Coplanarity: determined by the dominant (largest-area) face normal of each Blender mesh object — area-weighted averages are unreliable for slabs whose equal top/bottom faces cancel out. Folded walls sharing an edge but meeting at an angle are correctly rejected (normal dot ≪ 1.0). Co-Authored-By: Claude Sonnet 4.6 --- .../bonsai/bim/module/drawing/operator.py | 243 ++++++++++++++++-- 1 file changed, 217 insertions(+), 26 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/drawing/operator.py b/src/bonsai/bonsai/bim/module/drawing/operator.py index 45f67b0769..ebb505a4fa 100644 --- a/src/bonsai/bonsai/bim/module/drawing/operator.py +++ b/src/bonsai/bonsai/bim/module/drawing/operator.py @@ -1005,12 +1005,14 @@ class CreateDrawing(bpy.types.Operator): if self.cprops.generate_material_layers: self.generate_material_layers(context, root) self.merge_linework_and_add_metadata(root) + self.remove_coplanar_boundary_lines(root) self.move_elements_to_top(root) elif self.cprops.cut_mode == "OPENCASCADE": self.move_projection_to_bottom(root) if self.cprops.generate_material_layers: self.generate_material_layers(context, root) self.merge_linework_and_add_metadata(root) + self.remove_coplanar_boundary_lines(root) self.move_elements_to_top(root) if self.cprops.fill_mode == "SHAPELY": @@ -1088,6 +1090,7 @@ class CreateDrawing(bpy.types.Operator): if self.cprops.fill_mode == "SVGFILL": results = etree.tostring(root).decode("utf8") svg_data_1 = results + from collections import defaultdict from xml.dom.minidom import parseString def yield_groups(n): @@ -1102,8 +1105,19 @@ class CreateDrawing(bpy.types.Operator): ls_groups = ifcopenshell.ifcopenshell_wrapper.svg_to_line_segments(results, "projection") - for i, (ls, g1) in enumerate(zip(ls_groups, groups1)): - projection, g1 = g1, g1.parentNode + # Group projection elements by their parent section-view group so that all + # projection linework from the same view is merged in one cell decomposition. + # This enables coplanar surfaces from *different* elements to be joined. + groups_by_parent = defaultdict(list) + ls_by_parent = defaultdict(list) + for ls, g in zip(ls_groups, groups1): + pid = id(g.parentNode) + groups_by_parent[pid].append(g) + ls_by_parent[pid].extend(ls) + + for pid, projection_groups in groups_by_parent.items(): + section_parent = projection_groups[0].parentNode + combined_ls = ls_by_parent[pid] svgfill_context = ifcopenshell.ifcopenshell_wrapper.context( ifcopenshell.ifcopenshell_wrapper.EXACT_CONSTRUCTIONS, 1.0e-3 @@ -1111,10 +1125,11 @@ class CreateDrawing(bpy.types.Operator): # EXACT_CONSTRUCTIONS is significantly faster than FILTERED_CARTESIAN_QUOTIENT # remove duplicates (without tolerance) - ls = [l for l in map(tuple, set(map(frozenset, ls))) if len(l) == 2 and l[0] != l[1]] - svgfill_context.add(ls) + combined_ls = [l for l in map(tuple, set(map(frozenset, combined_ls))) if len(l) == 2 and l[0] != l[1]] + svgfill_context.add(combined_ls) - num_passes = 0 + num_passes = 1 + g2 = None for iteration in range(num_passes + 1): # initialize empty group, note that in the current approach only one @@ -1189,11 +1204,8 @@ class CreateDrawing(bpy.types.Operator): if iteration != num_passes: to_remove = [] + material_cache = {} for he_idx in range(0, len(pairs), 2): - # @todo instead of ray_distance, better do (x.point - y.point).dot(x.normal) - # to see if they're coplanar, because ray-distance will be different in case - # of element surfaces non-orthogonal to the view direction - def format(x): if x is None: return None @@ -1201,34 +1213,48 @@ class CreateDrawing(bpy.types.Operator): # found to be inside element using tree.select() no face or style info return x else: - return (x.instance.is_a(), x.ray_distance, tuple(x.position)) + return (x.instance, tuple(x.position), tuple(x.normal), x.style_index) pp = pairs[he_idx : he_idx + 2] if pp == (-1, -1): continue data = list(map(format, map(semantics.__getitem__, pp))) - if None not in data and data[0][0] == data[1][0] and abs(data[0][1] - data[1][1]) < 1.0e-5: - to_remove.append(he_idx // 2) - # Print edge index and semantic data - # print(he_idx // 2, *data) + if None not in data and data[0][0].is_a() == data[1][0].is_a(): + if len(data[0]) == 2 and len(data[1]) == 2: + # Both from tree.select() -> same element = same surface + if data[0][0] == data[1][0]: + to_remove.append(he_idx // 2) + elif len(data[0]) == 4 and len(data[1]) == 4: + # Both from tree.select_ray() -> coplanar + same style + same material + p1, n1, s1 = np.array(data[0][1]), np.array(data[0][2]), data[0][3] + p2, n2, s2 = np.array(data[1][1]), np.array(data[1][2]), data[1][3] + + if s1 == s2: + if abs(1.0 - abs(np.dot(n1, n2))) < 1.0e-4: + if abs(np.dot(p1 - p2, n1)) < 1.0e-4: + def get_cached_material(inst): + id_ = inst.id() + if id_ not in material_cache: + mats = ifcopenshell.util.element.get_materials(inst) + material_cache[id_] = tuple(m.id() for m in mats) if mats else (-1,) + return material_cache[id_] + + if get_cached_material(data[0][0]) == get_cached_material(data[1][0]): + to_remove.append(he_idx // 2) + # print(he_idx // 2, *data) svgfill_context.merge(to_remove) - # Swap the XML nodes from the files - # Remove the original hidden line node we still have in the serializer output - g1.removeChild(projection) + # Replace all per-element projection groups with one merged cell group. + # SVG draw order: projections must be below sections, so insert first. + for pg in projection_groups: + section_parent.removeChild(pg) g2.setAttribute("class", "projection") - # Find the children of the projection node parent - children = [x for x in g1.childNodes if x.nodeType == x.ELEMENT_NODE] + children = [x for x in section_parent.childNodes if x.nodeType == x.ELEMENT_NODE] if children: - # Insert the new semantically enriched cell-based projection node - # *before* the node with sections from the serializer. SVG derives - # draw order from node order in the DOM so sections are draw over - # the projections. - g1.insertBefore(g2, children[0]) + section_parent.insertBefore(g2, children[0]) else: - # This generally shouldn't happen - g1.appendChild(g2) + section_parent.appendChild(g2) results = dom1.toxml() results = results.encode("ascii", "xmlcharrefreplace") @@ -1600,6 +1626,171 @@ class CreateDrawing(bpy.types.Operator): g.set("class", " ".join(list(polygon_classes))) group.append(g) + def remove_coplanar_boundary_lines(self, root): + """Remove projection line segments shared between same-material elements. + + After merge_linework_and_add_metadata() adds material-* CSS classes, + this scans all per-element projection groups under each common + parent, finds path segments (M x0,y0 L x1,y1) that appear in two or + more groups that carry the same material-* class, and deletes them from + both groups so coplanar surfaces of the same material appear seamless. + """ + SVG = "http://www.w3.org/2000/svg" + TOL = 0.01 # SVG coordinate tolerance for matching line endpoints + + obj_cache = {} + + def get_obj(guid): + if guid in obj_cache: + return obj_cache[guid] + element = self.get_element_by_guid(guid) + obj = tool.Ifc.get_object(element) if element is not None else None + obj_cache[guid] = obj + return obj + + adjacency_cache = {} + + def are_coplanar_and_adjacent(guid_a, guid_b, tol=0.01): + """True if the two meshes share a vertex AND have parallel face normals. + + Sharing a vertex confirms physical adjacency (rules out depth-stacked elements + whose 2D projections accidentally overlap). Parallel normals confirms the + shared face is coplanar — elements meeting at a fold angle are rejected. + """ + key = (min(guid_a, guid_b), max(guid_a, guid_b)) + if key in adjacency_cache: + return adjacency_cache[key] + obj_a = get_obj(guid_a) + obj_b = get_obj(guid_b) + if obj_a is None or obj_b is None or obj_a.type != "MESH" or obj_b.type != "MESH": + adjacency_cache[key] = True + return True + # Quick AABB guard + corners_a = [obj_a.matrix_world @ Vector(c) for c in obj_a.bound_box] + corners_b = [obj_b.matrix_world @ Vector(c) for c in obj_b.bound_box] + for axis in range(3): + if min(c[axis] for c in corners_a) > max(c[axis] for c in corners_b) + tol: + adjacency_cache[key] = False + return False + if min(c[axis] for c in corners_b) > max(c[axis] for c in corners_a) + tol: + adjacency_cache[key] = False + return False + # Shared vertex check + tol_sq = tol * tol + verts_a = [obj_a.matrix_world @ v.co for v in obj_a.data.vertices] + verts_b = [obj_b.matrix_world @ v.co for v in obj_b.data.vertices] + has_shared = any((va - vb).length_squared < tol_sq for va in verts_a for vb in verts_b) + if not has_shared: + adjacency_cache[key] = False + return False + # Coplanarity check: use the largest-face normal for each object. + # Area-weighted averages fail for slabs because top/bottom faces cancel. + def dominant_world_normal(obj): + mat3 = obj.matrix_world.to_3x3().normalized() + best = max(obj.data.polygons, key=lambda p: p.area, default=None) + if best is None or best.area < 1e-10: + return None + return (mat3 @ best.normal).normalized() + + n_a = dominant_world_normal(obj_a) + n_b = dominant_world_normal(obj_b) + if n_a is None or n_b is None: + adjacency_cache[key] = True + return True + result = abs(n_a.dot(n_b)) > 1.0 - 1e-3 + adjacency_cache[key] = result + return result + + def parse_line(d): + # Format is "Mx0,y0 Lx1,y1" (no space after M/L) + parts = d.strip().split() + if len(parts) == 2 and parts[0].startswith("M") and parts[1].startswith("L"): + try: + x0, y0 = map(float, parts[0][1:].split(",")) + x1, y1 = map(float, parts[1][1:].split(",")) + return (x0, y0), (x1, y1) + except ValueError: + pass + return None + + def lines_match(a, b): + (x0a, y0a), (x1a, y1a) = a + (x0b, y0b), (x1b, y1b) = b + return ( + abs(x0a - x0b) < TOL and abs(y0a - y0b) < TOL and abs(x1a - x1b) < TOL and abs(y1a - y1b) < TOL + ) or ( + abs(x0a - x1b) < TOL and abs(y0a - y1b) < TOL and abs(x1a - x0b) < TOL and abs(y1a - y0b) < TOL + ) + + # Group projection elements by their immediate parent + parent_to_groups = {} + for g in root.iter(f"{{{SVG}}}g"): + cls_list = g.get("class", "").split() + if "projection" not in cls_list: + continue + parent = g.getparent() + if parent is None: + continue + parent_to_groups.setdefault(id(parent), []).append(g) + + for pid, proj_groups in parent_to_groups.items(): + if len(proj_groups) < 2: + continue + + def get_material_key(guid): + element = self.get_element_by_guid(guid) + if element is None: + return None + mats = ifcopenshell.util.element.get_materials(element) + return tuple(sorted(m.id() for m in mats)) if mats else () + + def get_style_key(guid): + """IDs of IfcPresentationStyles directly on the element's geometry items.""" + element = self.get_element_by_guid(guid) + if element is None or not getattr(element, "Representation", None): + return () + style_ids = set() + for rep in element.Representation.Representations: + for item in rep.Items: + for si in getattr(item, "StyledByItem", ()): + for style in si.Styles: + style_ids.add(style.id()) + return tuple(sorted(style_ids)) + + group_data = [] + for grp in proj_groups: + guid = grp.get("{http://www.ifcopenshell.org/ns}guid", "") + mat_key = get_material_key(guid) + style_key = get_style_key(guid) + segs = [] + for path_el in grp.findall(f"{{{SVG}}}path"): + line = parse_line(path_el.get("d", "")) + if line is not None: + segs.append((path_el, line)) + group_data.append((grp, mat_key, style_key, segs, guid)) + + to_remove = set() + for i, (grp_i, mat_i, style_i, segs_i, guid_i) in enumerate(group_data): + if mat_i is None: + continue + for j, (grp_j, mat_j, style_j, segs_j, guid_j) in enumerate(group_data): + if j <= i: + continue + if mat_j != mat_i or style_j != style_i: + continue + if not are_coplanar_and_adjacent(guid_i, guid_j): + continue + for path_i, line_i in segs_i: + for path_j, line_j in segs_j: + if lines_match(line_i, line_j): + to_remove.add(id(path_i)) + to_remove.add(id(path_j)) + if to_remove: + for grp, mat, style, segs, guid in group_data: + for path_el, _ in segs: + if id(path_el) in to_remove: + grp.remove(path_el) + def drawing_to_model_co(self, x: float, y: float) -> Vector: camera_xy = np.array((x, -y)) / self.scale / 1000 camera_xy += np.array((self.cprops.width / -2, self.cprops.height / 2)) # top left offset From 5436467fc56c90149bf3d325fe9119e9c183f8e5 Mon Sep 17 00:00:00 2001 From: Ryan Schultz Date: Sat, 4 Apr 2026 13:49:02 -0500 Subject: [PATCH 15/16] Whoops, this was supposed to be a PR... Revert "Fix #3742: Remove coplanar boundary lines between adjacent same-material elements in Bonsai SVG drawings" This reverts commit 1c7e134d78ebf6bd67bc7fdf8f1de07c0bc66978. --- .../bonsai/bim/module/drawing/operator.py | 243 ++---------------- 1 file changed, 26 insertions(+), 217 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/drawing/operator.py b/src/bonsai/bonsai/bim/module/drawing/operator.py index ebb505a4fa..45f67b0769 100644 --- a/src/bonsai/bonsai/bim/module/drawing/operator.py +++ b/src/bonsai/bonsai/bim/module/drawing/operator.py @@ -1005,14 +1005,12 @@ class CreateDrawing(bpy.types.Operator): if self.cprops.generate_material_layers: self.generate_material_layers(context, root) self.merge_linework_and_add_metadata(root) - self.remove_coplanar_boundary_lines(root) self.move_elements_to_top(root) elif self.cprops.cut_mode == "OPENCASCADE": self.move_projection_to_bottom(root) if self.cprops.generate_material_layers: self.generate_material_layers(context, root) self.merge_linework_and_add_metadata(root) - self.remove_coplanar_boundary_lines(root) self.move_elements_to_top(root) if self.cprops.fill_mode == "SHAPELY": @@ -1090,7 +1088,6 @@ class CreateDrawing(bpy.types.Operator): if self.cprops.fill_mode == "SVGFILL": results = etree.tostring(root).decode("utf8") svg_data_1 = results - from collections import defaultdict from xml.dom.minidom import parseString def yield_groups(n): @@ -1105,19 +1102,8 @@ class CreateDrawing(bpy.types.Operator): ls_groups = ifcopenshell.ifcopenshell_wrapper.svg_to_line_segments(results, "projection") - # Group projection elements by their parent section-view group so that all - # projection linework from the same view is merged in one cell decomposition. - # This enables coplanar surfaces from *different* elements to be joined. - groups_by_parent = defaultdict(list) - ls_by_parent = defaultdict(list) - for ls, g in zip(ls_groups, groups1): - pid = id(g.parentNode) - groups_by_parent[pid].append(g) - ls_by_parent[pid].extend(ls) - - for pid, projection_groups in groups_by_parent.items(): - section_parent = projection_groups[0].parentNode - combined_ls = ls_by_parent[pid] + for i, (ls, g1) in enumerate(zip(ls_groups, groups1)): + projection, g1 = g1, g1.parentNode svgfill_context = ifcopenshell.ifcopenshell_wrapper.context( ifcopenshell.ifcopenshell_wrapper.EXACT_CONSTRUCTIONS, 1.0e-3 @@ -1125,11 +1111,10 @@ class CreateDrawing(bpy.types.Operator): # EXACT_CONSTRUCTIONS is significantly faster than FILTERED_CARTESIAN_QUOTIENT # remove duplicates (without tolerance) - combined_ls = [l for l in map(tuple, set(map(frozenset, combined_ls))) if len(l) == 2 and l[0] != l[1]] - svgfill_context.add(combined_ls) + ls = [l for l in map(tuple, set(map(frozenset, ls))) if len(l) == 2 and l[0] != l[1]] + svgfill_context.add(ls) - num_passes = 1 - g2 = None + num_passes = 0 for iteration in range(num_passes + 1): # initialize empty group, note that in the current approach only one @@ -1204,8 +1189,11 @@ class CreateDrawing(bpy.types.Operator): if iteration != num_passes: to_remove = [] - material_cache = {} for he_idx in range(0, len(pairs), 2): + # @todo instead of ray_distance, better do (x.point - y.point).dot(x.normal) + # to see if they're coplanar, because ray-distance will be different in case + # of element surfaces non-orthogonal to the view direction + def format(x): if x is None: return None @@ -1213,48 +1201,34 @@ class CreateDrawing(bpy.types.Operator): # found to be inside element using tree.select() no face or style info return x else: - return (x.instance, tuple(x.position), tuple(x.normal), x.style_index) + return (x.instance.is_a(), x.ray_distance, tuple(x.position)) pp = pairs[he_idx : he_idx + 2] if pp == (-1, -1): continue data = list(map(format, map(semantics.__getitem__, pp))) - if None not in data and data[0][0].is_a() == data[1][0].is_a(): - if len(data[0]) == 2 and len(data[1]) == 2: - # Both from tree.select() -> same element = same surface - if data[0][0] == data[1][0]: - to_remove.append(he_idx // 2) - elif len(data[0]) == 4 and len(data[1]) == 4: - # Both from tree.select_ray() -> coplanar + same style + same material - p1, n1, s1 = np.array(data[0][1]), np.array(data[0][2]), data[0][3] - p2, n2, s2 = np.array(data[1][1]), np.array(data[1][2]), data[1][3] - - if s1 == s2: - if abs(1.0 - abs(np.dot(n1, n2))) < 1.0e-4: - if abs(np.dot(p1 - p2, n1)) < 1.0e-4: - def get_cached_material(inst): - id_ = inst.id() - if id_ not in material_cache: - mats = ifcopenshell.util.element.get_materials(inst) - material_cache[id_] = tuple(m.id() for m in mats) if mats else (-1,) - return material_cache[id_] - - if get_cached_material(data[0][0]) == get_cached_material(data[1][0]): - to_remove.append(he_idx // 2) - # print(he_idx // 2, *data) + if None not in data and data[0][0] == data[1][0] and abs(data[0][1] - data[1][1]) < 1.0e-5: + to_remove.append(he_idx // 2) + # Print edge index and semantic data + # print(he_idx // 2, *data) svgfill_context.merge(to_remove) - # Replace all per-element projection groups with one merged cell group. - # SVG draw order: projections must be below sections, so insert first. - for pg in projection_groups: - section_parent.removeChild(pg) + # Swap the XML nodes from the files + # Remove the original hidden line node we still have in the serializer output + g1.removeChild(projection) g2.setAttribute("class", "projection") - children = [x for x in section_parent.childNodes if x.nodeType == x.ELEMENT_NODE] + # Find the children of the projection node parent + children = [x for x in g1.childNodes if x.nodeType == x.ELEMENT_NODE] if children: - section_parent.insertBefore(g2, children[0]) + # Insert the new semantically enriched cell-based projection node + # *before* the node with sections from the serializer. SVG derives + # draw order from node order in the DOM so sections are draw over + # the projections. + g1.insertBefore(g2, children[0]) else: - section_parent.appendChild(g2) + # This generally shouldn't happen + g1.appendChild(g2) results = dom1.toxml() results = results.encode("ascii", "xmlcharrefreplace") @@ -1626,171 +1600,6 @@ class CreateDrawing(bpy.types.Operator): g.set("class", " ".join(list(polygon_classes))) group.append(g) - def remove_coplanar_boundary_lines(self, root): - """Remove projection line segments shared between same-material elements. - - After merge_linework_and_add_metadata() adds material-* CSS classes, - this scans all per-element projection groups under each common - parent, finds path segments (M x0,y0 L x1,y1) that appear in two or - more groups that carry the same material-* class, and deletes them from - both groups so coplanar surfaces of the same material appear seamless. - """ - SVG = "http://www.w3.org/2000/svg" - TOL = 0.01 # SVG coordinate tolerance for matching line endpoints - - obj_cache = {} - - def get_obj(guid): - if guid in obj_cache: - return obj_cache[guid] - element = self.get_element_by_guid(guid) - obj = tool.Ifc.get_object(element) if element is not None else None - obj_cache[guid] = obj - return obj - - adjacency_cache = {} - - def are_coplanar_and_adjacent(guid_a, guid_b, tol=0.01): - """True if the two meshes share a vertex AND have parallel face normals. - - Sharing a vertex confirms physical adjacency (rules out depth-stacked elements - whose 2D projections accidentally overlap). Parallel normals confirms the - shared face is coplanar — elements meeting at a fold angle are rejected. - """ - key = (min(guid_a, guid_b), max(guid_a, guid_b)) - if key in adjacency_cache: - return adjacency_cache[key] - obj_a = get_obj(guid_a) - obj_b = get_obj(guid_b) - if obj_a is None or obj_b is None or obj_a.type != "MESH" or obj_b.type != "MESH": - adjacency_cache[key] = True - return True - # Quick AABB guard - corners_a = [obj_a.matrix_world @ Vector(c) for c in obj_a.bound_box] - corners_b = [obj_b.matrix_world @ Vector(c) for c in obj_b.bound_box] - for axis in range(3): - if min(c[axis] for c in corners_a) > max(c[axis] for c in corners_b) + tol: - adjacency_cache[key] = False - return False - if min(c[axis] for c in corners_b) > max(c[axis] for c in corners_a) + tol: - adjacency_cache[key] = False - return False - # Shared vertex check - tol_sq = tol * tol - verts_a = [obj_a.matrix_world @ v.co for v in obj_a.data.vertices] - verts_b = [obj_b.matrix_world @ v.co for v in obj_b.data.vertices] - has_shared = any((va - vb).length_squared < tol_sq for va in verts_a for vb in verts_b) - if not has_shared: - adjacency_cache[key] = False - return False - # Coplanarity check: use the largest-face normal for each object. - # Area-weighted averages fail for slabs because top/bottom faces cancel. - def dominant_world_normal(obj): - mat3 = obj.matrix_world.to_3x3().normalized() - best = max(obj.data.polygons, key=lambda p: p.area, default=None) - if best is None or best.area < 1e-10: - return None - return (mat3 @ best.normal).normalized() - - n_a = dominant_world_normal(obj_a) - n_b = dominant_world_normal(obj_b) - if n_a is None or n_b is None: - adjacency_cache[key] = True - return True - result = abs(n_a.dot(n_b)) > 1.0 - 1e-3 - adjacency_cache[key] = result - return result - - def parse_line(d): - # Format is "Mx0,y0 Lx1,y1" (no space after M/L) - parts = d.strip().split() - if len(parts) == 2 and parts[0].startswith("M") and parts[1].startswith("L"): - try: - x0, y0 = map(float, parts[0][1:].split(",")) - x1, y1 = map(float, parts[1][1:].split(",")) - return (x0, y0), (x1, y1) - except ValueError: - pass - return None - - def lines_match(a, b): - (x0a, y0a), (x1a, y1a) = a - (x0b, y0b), (x1b, y1b) = b - return ( - abs(x0a - x0b) < TOL and abs(y0a - y0b) < TOL and abs(x1a - x1b) < TOL and abs(y1a - y1b) < TOL - ) or ( - abs(x0a - x1b) < TOL and abs(y0a - y1b) < TOL and abs(x1a - x0b) < TOL and abs(y1a - y0b) < TOL - ) - - # Group projection elements by their immediate parent - parent_to_groups = {} - for g in root.iter(f"{{{SVG}}}g"): - cls_list = g.get("class", "").split() - if "projection" not in cls_list: - continue - parent = g.getparent() - if parent is None: - continue - parent_to_groups.setdefault(id(parent), []).append(g) - - for pid, proj_groups in parent_to_groups.items(): - if len(proj_groups) < 2: - continue - - def get_material_key(guid): - element = self.get_element_by_guid(guid) - if element is None: - return None - mats = ifcopenshell.util.element.get_materials(element) - return tuple(sorted(m.id() for m in mats)) if mats else () - - def get_style_key(guid): - """IDs of IfcPresentationStyles directly on the element's geometry items.""" - element = self.get_element_by_guid(guid) - if element is None or not getattr(element, "Representation", None): - return () - style_ids = set() - for rep in element.Representation.Representations: - for item in rep.Items: - for si in getattr(item, "StyledByItem", ()): - for style in si.Styles: - style_ids.add(style.id()) - return tuple(sorted(style_ids)) - - group_data = [] - for grp in proj_groups: - guid = grp.get("{http://www.ifcopenshell.org/ns}guid", "") - mat_key = get_material_key(guid) - style_key = get_style_key(guid) - segs = [] - for path_el in grp.findall(f"{{{SVG}}}path"): - line = parse_line(path_el.get("d", "")) - if line is not None: - segs.append((path_el, line)) - group_data.append((grp, mat_key, style_key, segs, guid)) - - to_remove = set() - for i, (grp_i, mat_i, style_i, segs_i, guid_i) in enumerate(group_data): - if mat_i is None: - continue - for j, (grp_j, mat_j, style_j, segs_j, guid_j) in enumerate(group_data): - if j <= i: - continue - if mat_j != mat_i or style_j != style_i: - continue - if not are_coplanar_and_adjacent(guid_i, guid_j): - continue - for path_i, line_i in segs_i: - for path_j, line_j in segs_j: - if lines_match(line_i, line_j): - to_remove.add(id(path_i)) - to_remove.add(id(path_j)) - if to_remove: - for grp, mat, style, segs, guid in group_data: - for path_el, _ in segs: - if id(path_el) in to_remove: - grp.remove(path_el) - def drawing_to_model_co(self, x: float, y: float) -> Vector: camera_xy = np.array((x, -y)) / self.scale / 1000 camera_xy += np.array((self.cprops.width / -2, self.cprops.height / 2)) # top left offset From ab7d9fdf4a519b74ca82bde48ac9518fee9683d9 Mon Sep 17 00:00:00 2001 From: Ryan Schultz Date: Sun, 5 Apr 2026 16:43:03 -0500 Subject: [PATCH 16/16] Auto-assign aggregate on eyedropper pick Add update callbacks to the relating_object and related_object PointerProperties so that selecting an object via the eyedropper in BIM_PT_aggregate immediately calls aggregate_assign_object and closes the editing panel, removing the need to click the checkmark button manually. Generated with the assistance of an AI coding tool. --- .../bonsai/bim/module/aggregate/prop.py | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/src/bonsai/bonsai/bim/module/aggregate/prop.py b/src/bonsai/bonsai/bim/module/aggregate/prop.py index 0595c200c5..424f2b829f 100644 --- a/src/bonsai/bonsai/bim/module/aggregate/prop.py +++ b/src/bonsai/bonsai/bim/module/aggregate/prop.py @@ -73,6 +73,22 @@ def poll_related_object(self: "BIMObjectAggregateProperties", related_obj: bpy.t return True +def update_relating_object(self, context): + if self.relating_object: + ifc_id = tool.Blender.get_object_bim_props(self.relating_object).ifc_definition_id + if ifc_id: + bpy.ops.bim.aggregate_assign_object(relating_object=ifc_id) + bpy.ops.bim.disable_editing_aggregate() + + +def update_related_object(self, context): + if self.related_object: + ifc_id = tool.Blender.get_object_bim_props(self.related_object).ifc_definition_id + if ifc_id: + bpy.ops.bim.aggregate_assign_object(related_object=ifc_id) + bpy.ops.bim.disable_editing_aggregate() + + def update_aggregate_decorator(self, context): if self.aggregate_decorator: AggregateDecorator.install(bpy.context) @@ -89,12 +105,15 @@ def update_aggregate_mode_decorator(self, context): class BIMObjectAggregateProperties(PropertyGroup): is_editing: BoolProperty(name="Is Editing") - relating_object: PointerProperty(name="Relating Whole", type=bpy.types.Object, poll=poll_relating_object) + relating_object: PointerProperty( + name="Relating Whole", type=bpy.types.Object, poll=poll_relating_object, update=update_relating_object + ) related_object: PointerProperty( name="Related Part", description="Related Part, will be used to derive the Relating Object", type=bpy.types.Object, poll=poll_related_object, + update=update_related_object, ) if TYPE_CHECKING: