From e0aa39068d4bfb7598d76da8524e4f3eb9eece6e Mon Sep 17 00:00:00 2001 From: Gorgious56 Date: Wed, 3 Jun 2026 16:44:38 +0200 Subject: [PATCH] Shift-click add-opening preserves filling placement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The regular bim.add_opening click on the host-add-opening gizmo (wall + door/window co-selected) routes through FilledOpeningGenerator.generate, which snaps the filling to the wall's reference-line axis, optionally rotates 180° when the filling sits on the opposite side, and re-applies an rl1 / rl2 Z-elevation default. That is the right default for "drag a fresh door onto a wall and let the model place it for me", but defeats the workflow where the user has already positioned the filling precisely (e.g. snapped to a window in an adjacent wall, copy- pasted at an exact Z, aligned to a reference object). Holding SHIFT while clicking the gizmo now opts into a "preserve placement" mode: the filling stays at its current matrix_world and the opening is created at the filling's existing position. The opening / filling rels and representation work are unchanged — only the snap-to-axis branch is skipped, so the IFC graph is identical to the regular click; only the spatial position of the filling differs (user-chosen vs auto-snapped). Implementation: * bim/module/void/operator.py: AddOpening gains a hidden preserve_placement BoolProperty + an invoke() that sets it from event.shift. The call into FilledOpeningGenerator.generate forwards the flag. bl_description documents the SHIFT modifier so it surfaces in F3 search / hover tooltip. * bim/module/model/opening.py: FilledOpeningGenerator.generate accepts preserve_placement (default False — backwards-compatible with the other caller, tool.Model.add_filled_opening). The voided_obj.data-gated snap block (raycast + axis projection + rl-Z default + filling_obj.matrix_world write) skips entirely when the flag is True. The opening's matrix_world reads from filling_obj.matrix_world below the gate, so the opening lands at the filling's preserved position automatically. Generated with the assistance of an AI coding tool. --- src/bonsai/bonsai/bim/module/model/opening.py | 8 ++++++- src/bonsai/bonsai/bim/module/void/operator.py | 21 +++++++++++++++++-- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/model/opening.py b/src/bonsai/bonsai/bim/module/model/opening.py index cc69586191..c46ac25094 100644 --- a/src/bonsai/bonsai/bim/module/model/opening.py +++ b/src/bonsai/bonsai/bim/module/model/opening.py @@ -229,9 +229,15 @@ class FilledOpeningGenerator: filling_obj: bpy.types.Object, voided_obj: bpy.types.Object, target: Optional[Vector] = None, + preserve_placement: bool = False, ) -> Union[None, str]: """ :param target: Target opening position. If ommited, cursor position is used. + :param preserve_placement: If True, keep ``filling_obj.matrix_world`` as-is + and skip the snap-to-wall-axis / rl1-rl2 Z-default logic. The opening + is still created at the filling's current world position. Useful + when the caller (e.g. the SHIFT-add-opening gizmo flow) has + already positioned the filling intentionally. :return: None if there was no errors, otherwise returns a string with error message. """ props = tool.Model.get_model_props() @@ -253,7 +259,7 @@ class FilledOpeningGenerator: should_set_z_level = False # Sometimes, the voided_obj may be an aggregate, which won't have any representation. - if voided_obj.data: + if not preserve_placement and voided_obj.data: raycast = voided_obj.closest_point_on_mesh(voided_obj.matrix_world.inverted() @ target, distance=0.01) if not raycast[0]: target = filling_obj.matrix_world.translation.copy() diff --git a/src/bonsai/bonsai/bim/module/void/operator.py b/src/bonsai/bonsai/bim/module/void/operator.py index c27b8891f6..4ff021630d 100644 --- a/src/bonsai/bonsai/bim/module/void/operator.py +++ b/src/bonsai/bonsai/bim/module/void/operator.py @@ -36,9 +36,17 @@ class AddOpening(bpy.types.Operator, tool.Ifc.Operator): bl_description = ( "Apply opening objects to an Element.\n\n" "The Element and the openings to be applied should be selected. The order of selection is not important.\n" - "Opening can be just a Blender mesh object." + "Opening can be just a Blender mesh object.\n\n" + "Shift+click: keep the filling at its current matrix_world — skip the wall-axis snap " + "and the rl1/rl2 Z-elevation default that the regular click applies." ) + # Toggled by ``invoke`` when the user holds SHIFT during a gizmo / hotkey + # click. Forwards to ``FilledOpeningGenerator.generate`` which gates the + # snap-to-wall-axis block on it. HIDDEN + SKIP_SAVE so it doesn't surface + # in the F6 redo panel or persist into saved keymaps. + preserve_placement: bpy.props.BoolProperty(default=False, options={"HIDDEN", "SKIP_SAVE"}) + @classmethod def poll(cls, context): if len(context.selected_objects) < 2: @@ -46,6 +54,10 @@ class AddOpening(bpy.types.Operator, tool.Ifc.Operator): return False return True + def invoke(self, context, event): + self.preserve_placement = bool(event.shift) + return self.execute(context) + def _execute(self, context): selected_objects = context.selected_objects target_object = selected_objects[0] @@ -68,7 +80,12 @@ class AddOpening(bpy.types.Operator, tool.Ifc.Operator): elif not element1.is_a("IfcOpeningElement") and not element2.is_a("IfcOpeningElement"): if element1.is_a("IfcWindow") or element1.is_a("IfcDoor"): # Add a fill to an element. obj1, obj2 = obj2, obj1 - FilledOpeningGenerator().generate(obj2, obj1, target=obj2.matrix_world.translation) + FilledOpeningGenerator().generate( + obj2, + obj1, + target=obj2.matrix_world.translation, + preserve_placement=self.preserve_placement, + ) continue elif element1.is_a("IfcOpeningElement") or element2.is_a("IfcOpeningElement"): if element1.is_a("IfcOpeningElement"): # Reassign an opening to another element.