Shift-click add-opening preserves filling placement

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.
This commit is contained in:
Gorgious56
2026-06-03 16:44:38 +02:00
committed by Thomas Krijnen
parent 81bacb5899
commit e0aa39068d
2 changed files with 26 additions and 3 deletions
@@ -229,9 +229,15 @@ class FilledOpeningGenerator:
filling_obj: bpy.types.Object, filling_obj: bpy.types.Object,
voided_obj: bpy.types.Object, voided_obj: bpy.types.Object,
target: Optional[Vector] = None, target: Optional[Vector] = None,
preserve_placement: bool = False,
) -> Union[None, str]: ) -> Union[None, str]:
""" """
:param target: Target opening position. If ommited, cursor position is used. :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. :return: None if there was no errors, otherwise returns a string with error message.
""" """
props = tool.Model.get_model_props() props = tool.Model.get_model_props()
@@ -253,7 +259,7 @@ class FilledOpeningGenerator:
should_set_z_level = False should_set_z_level = False
# Sometimes, the voided_obj may be an aggregate, which won't have any representation. # 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) raycast = voided_obj.closest_point_on_mesh(voided_obj.matrix_world.inverted() @ target, distance=0.01)
if not raycast[0]: if not raycast[0]:
target = filling_obj.matrix_world.translation.copy() target = filling_obj.matrix_world.translation.copy()
+19 -2
View File
@@ -36,9 +36,17 @@ class AddOpening(bpy.types.Operator, tool.Ifc.Operator):
bl_description = ( bl_description = (
"Apply opening objects to an Element.\n\n" "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" "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 @classmethod
def poll(cls, context): def poll(cls, context):
if len(context.selected_objects) < 2: if len(context.selected_objects) < 2:
@@ -46,6 +54,10 @@ class AddOpening(bpy.types.Operator, tool.Ifc.Operator):
return False return False
return True return True
def invoke(self, context, event):
self.preserve_placement = bool(event.shift)
return self.execute(context)
def _execute(self, context): def _execute(self, context):
selected_objects = context.selected_objects selected_objects = context.selected_objects
target_object = selected_objects[0] 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"): 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. if element1.is_a("IfcWindow") or element1.is_a("IfcDoor"): # Add a fill to an element.
obj1, obj2 = obj2, obj1 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 continue
elif element1.is_a("IfcOpeningElement") or element2.is_a("IfcOpeningElement"): elif element1.is_a("IfcOpeningElement") or element2.is_a("IfcOpeningElement"):
if element1.is_a("IfcOpeningElement"): # Reassign an opening to another element. if element1.is_a("IfcOpeningElement"): # Reassign an opening to another element.