From 028e593939bf168fb3709723b0450b30b5e6ec0e Mon Sep 17 00:00:00 2001 From: Ryan Schultz Date: Thu, 2 Jul 2026 16:02:17 -0500 Subject: [PATCH] Bonsai: per-row lock toggle with auto-saved link transforms Link editing moves from the links header row into each list row as a lock/unlock toggle. Unlocking (bim.enable_editing_link) frees the handle for moving; any transform is persisted immediately by a depsgraph_update_post handler, so bim.edit_link and its explicit save step are removed. Locking (bim.disable_editing_link) saves the current location and locks the handle instead of restoring the old position - cancel/restore semantics no longer exist. The save math from EditLink now lives in tool.Project.save_link_transformation. Enable/disable operators accept a link_index (default -1 = active link), so several links can be edited at once and script calls stay backward compatible. Co-Authored-By: Claude Fable 5 --- .../bonsai/bim/module/project/__init__.py | 29 +++++++- .../bonsai/bim/module/project/operator.py | 71 +++++-------------- src/bonsai/bonsai/bim/module/project/ui.py | 9 ++- src/bonsai/bonsai/tool/project.py | 40 +++++++++++ 4 files changed, 88 insertions(+), 61 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/project/__init__.py b/src/bonsai/bonsai/bim/module/project/__init__.py index 18046d06d0..36c0d085f5 100644 --- a/src/bonsai/bonsai/bim/module/project/__init__.py +++ b/src/bonsai/bonsai/bim/module/project/__init__.py @@ -45,7 +45,6 @@ classes = ( operator.DisableEditingHeader, operator.DisableEditingLink, operator.EditHeader, - operator.EditLink, operator.EditProjectLibrary, operator.EnableCulling, operator.EnableEditingHeader, @@ -110,12 +109,38 @@ classes = ( addon_keymaps = [] +@bpy.app.handlers.persistent +def _autosave_link_transforms(scene, depsgraph): + """Persist link transformations whenever an editing link's handle is moved.""" + import bonsai.tool as tool + + props = tool.Project.get_project_props() + if not props.links: + return + handles = None + for update in depsgraph.updates: + if not update.is_updated_transform or not isinstance(update.id, bpy.types.Object): + continue + if handles is None: + # Built lazily so ticks without transform updates stay cheap. + handles = {} + for link in props.links: + if link.is_loaded and link.is_editing and (handle := tool.Project.get_link_empty_handle(link)): + handles[handle] = link + if not handles: + return + if link := handles.get(update.id.original): + tool.Project.save_link_transformation(link) + + def register(): if not bpy.app.background: bpy.utils.register_tool(workspace.ExploreTool, after={"builtin.transform"}, separator=True, group=False) bpy.types.Scene.BIMProjectProperties = bpy.props.PointerProperty(type=prop.BIMProjectProperties) bpy.types.Scene.MeasureToolSettings = bpy.props.PointerProperty(type=prop.MeasureToolSettings) bpy.app.handlers.load_post.append(decorator.toggle_decorations_on_load) + if _autosave_link_transforms not in bpy.app.handlers.depsgraph_update_post: + bpy.app.handlers.depsgraph_update_post.append(_autosave_link_transforms) bpy.types.TOPBAR_MT_file_import.append(ui.file_import_menu) bpy.types.TOPBAR_MT_file.prepend(ui.file_menu) bpy.types.TOPBAR_MT_file_context_menu.prepend(ui.file_menu) @@ -140,6 +165,8 @@ def unregister(): del bpy.types.Scene.BIMProjectProperties del bpy.types.Scene.MeasureToolSettings bpy.app.handlers.load_post.remove(decorator.toggle_decorations_on_load) + if _autosave_link_transforms in bpy.app.handlers.depsgraph_update_post: + bpy.app.handlers.depsgraph_update_post.remove(_autosave_link_transforms) bpy.types.TOPBAR_MT_file.remove(ui.file_menu) bpy.types.TOPBAR_MT_file_context_menu.remove(ui.file_menu) diff --git a/src/bonsai/bonsai/bim/module/project/operator.py b/src/bonsai/bonsai/bim/module/project/operator.py index 4c35ac98f1..4bde9c15bb 100644 --- a/src/bonsai/bonsai/bim/module/project/operator.py +++ b/src/bonsai/bonsai/bim/module/project/operator.py @@ -1904,10 +1904,16 @@ class EnableEditingLink(bpy.types.Operator): bl_idname = "bim.enable_editing_link" bl_label = "Enable Editing Link" bl_options = {"REGISTER", "UNDO"} - bl_description = "Enable editing link location" + bl_description = "Unlock the link's position for editing. Any movement is saved automatically" + + link_index: bpy.props.IntProperty(name="Link Index", default=-1) + + if TYPE_CHECKING: + link_index: int def execute(self, context): - link = tool.Project.get_project_props().active_link + props = tool.Project.get_project_props() + link = props.active_link if self.link_index == -1 else props.links[self.link_index] assert link link.is_editing = True obj = tool.Project.get_link_empty_handle(link) @@ -1916,70 +1922,25 @@ class EnableEditingLink(bpy.types.Operator): return {"FINISHED"} -class DisableEditingLink(bpy.types.Operator): +class DisableEditingLink(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.disable_editing_link" bl_label = "Disable Editing Link" bl_options = {"REGISTER", "UNDO"} - bl_description = "Disable editing link and restore to previously saved location" + bl_description = "Lock the link at its current location" - def execute(self, context): - link = tool.Project.get_project_props().active_link - assert link - link.is_editing = False - obj = tool.Project.get_link_empty_handle(link) - assert obj - obj.matrix_world = tool.Project.calculate_link_matrix(link) - tool.Geometry.lock_object(obj) - return {"FINISHED"} + link_index: bpy.props.IntProperty(name="Link Index", default=-1) - -class EditLink(bpy.types.Operator, tool.Ifc.Operator): - bl_idname = "bim.edit_link" - bl_label = "Edit Link" - bl_options = {"REGISTER", "UNDO"} - bl_description = "Disable editing link and restore to previously saved location" + if TYPE_CHECKING: + link_index: int def _execute(self, context): - link = tool.Project.get_project_props().active_link + props = tool.Project.get_project_props() + link = props.active_link if self.link_index == -1 else props.links[self.link_index] assert link link.is_editing = False obj = tool.Project.get_link_empty_handle(link) assert obj - new_obj_matrix = obj.matrix_world - - filepath = Path(tool.Ifc.resolve_uri(link.filepath)) - with open(filepath.with_suffix(".ifc.cache.json"), "r") as f: - metadata = json.load(f) - - rot = ifcopenshell.util.shape_builder.np_rotation_matrix( - radians(-float(metadata["model_project_north"])), 4, "Z" - ) - global_matrix = rot @ np.eye(4) - global_matrix[:, 3][:3] = [float(o) for o in metadata["model_origin_si"].split(",")] - - gprops = tool.Georeference.get_georeference_props() - rot = ifcopenshell.util.shape_builder.np_rotation_matrix(radians(-float(gprops.model_project_north)), 4, "Z") - local_matrix = rot @ np.eye(4) - local_matrix[:, 3][:3] = [float(o) for o in gprops.model_origin_si.split(",")] - - # obj_matrix is typically calculated as: - # obj_matrix = np.linalg.inv(local_matrix) @ transformation @ global_matrix - identity_blender_matrix = np.linalg.inv(local_matrix) @ global_matrix - if np.allclose(np.array(new_obj_matrix), identity_blender_matrix, atol=1e-5): - link.has_transformation = False - transformation = ",".join(map(str, np.eye(4).reshape(-1))) - else: - transformed_global_matrix = local_matrix @ np.array(new_obj_matrix) - transformation = transformed_global_matrix @ np.linalg.inv(global_matrix) - link.has_transformation = True - transformation = ",".join(map(str, transformation.reshape(-1))) - - if tool.Ifc.get(): - reference = tool.Ifc.get().by_id(link.ifc_definition_id) - reference[1] = transformation - else: - link.transformation = transformation - + tool.Project.save_link_transformation(link) obj.matrix_world = tool.Project.calculate_link_matrix(link) tool.Geometry.lock_object(obj) diff --git a/src/bonsai/bonsai/bim/module/project/ui.py b/src/bonsai/bonsai/bim/module/project/ui.py index 5e828ddd9b..3c6473f251 100644 --- a/src/bonsai/bonsai/bim/module/project/ui.py +++ b/src/bonsai/bonsai/bim/module/project/ui.py @@ -498,11 +498,6 @@ class BIM_PT_links(Panel): row.alignment = "RIGHT" index = self.props.active_link_index if self.props.active_link.is_loaded: - if self.props.active_link.is_editing: - row.operator("bim.edit_link", text="", icon="CHECKMARK") - row.operator("bim.disable_editing_link", text="", icon="CANCEL") - else: - row.operator("bim.enable_editing_link", text="", icon="GREASEPENCIL") row.operator("bim.select_linked_model_element", icon="VIEWZOOM", text="") row.operator("bim.select_link_handle", text="", icon="OBJECT_DATA").link_index = index row.operator("bim.unload_link", text="", icon="UNLINKED").link_index = index @@ -644,6 +639,10 @@ class BIM_UL_links(UIList): row.label(text="", icon="OBJECT_ORIGIN") row.label(text=item.filepath) + if item.is_editing: + row.operator("bim.disable_editing_link", text="", icon="UNLOCKED", emboss=False).link_index = index + else: + row.operator("bim.enable_editing_link", text="", icon="LOCKED", emboss=False).link_index = index icon = "RESTRICT_SELECT_OFF" if item.is_selectable else "RESTRICT_SELECT_ON" row.operator("bim.toggle_link_selectability", text="", icon=icon, emboss=False).link_index = index icon = "CUBE" if item.is_wireframe else "MESH_CUBE" diff --git a/src/bonsai/bonsai/tool/project.py b/src/bonsai/bonsai/tool/project.py index 7199c125cf..c3990fa5dc 100644 --- a/src/bonsai/bonsai/tool/project.py +++ b/src/bonsai/bonsai/tool/project.py @@ -117,6 +117,46 @@ class Project(bonsai.core.tool.Project): local_matrix[:, 3][:3] = [float(o) for o in gprops.model_origin_si.split(",")] return Matrix(np.linalg.inv(local_matrix) @ global_matrix) + @classmethod + def save_link_transformation(cls, link: Link) -> None: + """Persist the link handle's current world matrix as the link's saved transformation.""" + obj = cls.get_link_empty_handle(link) + assert obj + new_obj_matrix = np.array(obj.matrix_world) + + filepath = Path(tool.Ifc.resolve_uri(link.filepath)) + with open(filepath.with_suffix(".ifc.cache.json"), "r") as f: + metadata = json.load(f) + + rot = ifcopenshell.util.shape_builder.np_rotation_matrix( + radians(-float(metadata["model_project_north"])), 4, "Z" + ) + global_matrix = rot @ np.eye(4) + global_matrix[:, 3][:3] = [float(o) for o in metadata["model_origin_si"].split(",")] + + gprops = tool.Georeference.get_georeference_props() + rot = ifcopenshell.util.shape_builder.np_rotation_matrix(radians(-float(gprops.model_project_north)), 4, "Z") + local_matrix = rot @ np.eye(4) + local_matrix[:, 3][:3] = [float(o) for o in gprops.model_origin_si.split(",")] + + # obj_matrix is typically calculated as: + # obj_matrix = np.linalg.inv(local_matrix) @ transformation @ global_matrix + identity_blender_matrix = np.linalg.inv(local_matrix) @ global_matrix + if np.allclose(new_obj_matrix, identity_blender_matrix, atol=1e-5): + link.has_transformation = False + transformation = ",".join(map(str, np.eye(4).reshape(-1))) + else: + transformed_global_matrix = local_matrix @ new_obj_matrix + transformation = transformed_global_matrix @ np.linalg.inv(global_matrix) + link.has_transformation = True + transformation = ",".join(map(str, transformation.reshape(-1))) + + if tool.Ifc.get(): + reference = tool.Ifc.get().by_id(link.ifc_definition_id) + reference[1] = transformation + else: + link.transformation = transformation + @classmethod def append_all_types_from_template(cls, template: str) -> None: # TODO refactor