closes #3565 - extend profiles and basic extrusions to 3D cursor with Ctrl+E

Previously Ctrl+E only worked for LAYER2 elements (walls) to extend
height to cursor Z position. This adds support for:

- PROFILE elements (beams, columns, members) - extends to cursor in 3D
- Basic extrusions - extends along extrusion direction to cursor
- Multiple objects simultaneously using join_type="E"
This commit is contained in:
Ryan Schultz
2026-02-07 14:39:12 -06:00
parent 93c6350e0f
commit 28913afc12
2 changed files with 109 additions and 28 deletions
@@ -282,24 +282,37 @@ class ExtendProfile(bpy.types.Operator, tool.Ifc.Operator):
def _execute(self, context):
selected_objs = context.selected_objects
joiner = DumbProfileJoiner()
# Handle unjoin
if self.join_type == "-":
for obj in selected_objs:
joiner.unjoin(obj)
return {"FINISHED"}
if not context.active_object:
return {"FINISHED"}
for obj in selected_objs:
tool.Geometry.clear_scale(obj)
# NEW: Extend all selected objects to cursor
if self.join_type == "E":
self._extend_to_cursor(selected_objs, context.scene.cursor.location)
return {"FINISHED"}
# Single object - extend to cursor
if len(selected_objs) == 1:
joiner.join_E(context.active_object, context.scene.cursor.location)
return {"FINISHED"}
# Two objects - L or V joints
if len(selected_objs) == 2:
if self.join_type == "L":
joiner.join_L(next(o for o in selected_objs if o != context.active_object), context.active_object)
elif self.join_type == "V":
joiner.join_V(next(o for o in selected_objs if o != context.active_object), context.active_object)
# Multiple objects - T joints
if len(selected_objs) < 2:
return {"FINISHED"}
if self.join_type == "T":
@@ -309,6 +322,59 @@ class ExtendProfile(bpy.types.Operator, tool.Ifc.Operator):
joiner.join_T(obj, context.active_object)
return {"FINISHED"}
def _extend_to_cursor(self, objects: list[bpy.types.Object], cursor_location: Vector) -> None:
"""Extend profiles or basic extrusions to cursor location."""
joiner = DumbProfileJoiner()
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
for obj in objects:
element = tool.Ifc.get_entity(obj)
if not element:
continue
usage = tool.Model.get_usage_type(element)
if usage == "PROFILE":
# Use existing profile logic
joiner.join_E(obj, cursor_location)
else:
# Handle basic extrusions
representation = tool.Geometry.get_active_representation(obj)
extrusion = tool.Model.get_extrusion(representation) if representation else None
if not extrusion:
continue
# Get extrusion data
if extrusion.Position:
position = Matrix(ifcopenshell.util.placement.get_axis2placement(extrusion.Position).tolist())
position.translation *= unit_scale
else:
position = Matrix()
# Get extrusion direction in world space
direction = Vector(extrusion.ExtrudedDirection.DirectionRatios).normalized()
extrusion_start = obj.matrix_world @ position.translation
extrusion_direction_world = (obj.matrix_world.to_quaternion() @ position.to_quaternion() @ direction).normalized()
# Project cursor onto extrusion axis
cursor_vector = cursor_location - extrusion_start
projection_length = cursor_vector.dot(extrusion_direction_world)
if projection_length > 0:
new_depth = projection_length / unit_scale
# Update extrusion depth directly in IFC
extrusion.Depth = new_depth
# Regenerate geometry
bonsai.core.geometry.switch_representation(
tool.Ifc,
tool.Geometry,
obj=obj,
representation=representation,
)
class DumbProfileJoiner:
+43 -28
View File
@@ -1453,44 +1453,59 @@ class Hotkey(bpy.types.Operator, tool.Ifc.Operator):
cursor_z = bpy.context.scene.cursor.location.z
layer2_objects = []
layer2_bases = []
other_objects = []
for obj in bpy.context.selected_objects:
element = tool.Ifc.get_entity(obj)
if element and tool.Model.get_usage_type(element) == "LAYER2":
if not element:
continue
usage = tool.Model.get_usage_type(element)
if usage == "LAYER2":
obj_base_z = obj.matrix_world.translation.z
layer2_objects.append(obj)
layer2_bases.append(obj_base_z)
else:
other_objects.append(obj)
if not layer2_objects:
self.report({"ERROR"}, "No LAYER2 objects selected")
return
# Handle LAYER2 objects - extend height to cursor Z
if layer2_objects:
tolerance = 1e-5
if layer2_bases and (max(layer2_bases) - min(layer2_bases)) > tolerance:
min_base = min(layer2_bases)
max_base = max(layer2_bases)
self.report(
{"ERROR"},
f"Selected LAYER2 objects have different base heights ({min_base:.3f}m to {max_base:.3f}m). "
f"All objects must be at the exact same base level (tolerance {tolerance}).",
)
return
# --- tolerance check ---
tolerance = 1e-5 # to provide a little wiggle room
if layer2_bases and (max(layer2_bases) - min(layer2_bases)) > tolerance:
min_base = min(layer2_bases)
max_base = max(layer2_bases)
self.report(
{"ERROR"},
f"Selected LAYER2 objects have different base heights ({min_base:.3f}m to {max_base:.3f}m). "
f"All objects must be at the exact same base level (tolerance {tolerance}).",
)
return
common_base = sum(layer2_bases) / len(layer2_bases)
new_height = cursor_z - common_base
# use the mean base as the "common" one to avoid floating-point mismatches
common_base = sum(layer2_bases) / len(layer2_bases)
new_height = cursor_z - common_base
if new_height > 0:
props = tool.Model.get_model_props()
props.extrusion_depth = new_height
bpy.ops.bim.change_extrusion_depth(depth=new_height)
self.report({"INFO"}, f"Extended {len(layer2_objects)} LAYER2 object(s) to z: {cursor_z:.2f}m")
else:
self.report(
{"ERROR"},
f"Negative height not allowed. Cursor ({cursor_z:.2f}m) must be above object base ({common_base:.2f}m)",
)
if new_height > 0:
props = tool.Model.get_model_props()
props.extrusion_depth = new_height
bpy.ops.bim.change_extrusion_depth(depth=new_height)
self.report({"INFO"}, f"Extended {len(layer2_objects)} LAYER2 object(s) to z: {cursor_z:.2f}m")
else:
self.report(
{"ERROR"},
f"Negative height not allowed. Cursor ({cursor_z:.2f}m) must be above object base ({common_base:.2f}m)",
)
# Handle PROFILE objects and basic extrusions - extend to 3D cursor
if other_objects:
# Temporarily deselect layer2 objects
for obj in layer2_objects:
obj.select_set(False)
bpy.ops.bim.extend_profile(join_type="E")
# Restore selection
for obj in layer2_objects:
obj.select_set(True)
custom_icon_previews = None