Compare commits

...

4 Commits

Author SHA1 Message Date
Ryan Schultz 648c328d37 Merge commit '0c993d3292' into extend-profiles-and-extrusions 2026-06-11 10:10:46 -05:00
Ryan Schultz e9ebba9050 Add Extend to Cursor button to active tool panel
- Rename PROFILE panel button from "Extend Height" to "Extend to Cursor"
  with corrected description
- Add "Extend to Cursor" button for basic extrusions (has_extrusion)
  in the else branch of draw_operations
- Add extend_to_cursor icon files (copies of extend icon)

Generated with the assistance of an AI coding tool.
2026-06-07 18:36:24 -05:00
Ryan Schultz 9013dd2c35 Add "E" to ExtendProfile join_type enum
Generated with the assistance of an AI coding tool.
2026-06-07 18:36:23 -05:00
Ryan Schultz 28913afc12 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"
2026-06-07 18:35:56 -05:00
4 changed files with 117 additions and 31 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 308 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 346 B

+68 -2
View File
@@ -271,8 +271,8 @@ class ExtendProfile(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.extend_profile"
bl_label = "Extend Profile"
bl_options = {"REGISTER", "UNDO"}
join_type: bpy.props.EnumProperty(
items=[("-", "Unjoin", ""), ("L", "L", ""), ("V", "V", ""), ("T", "T", "")],
join_type: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration]
items=[("-", "Unjoin", ""), ("L", "L", ""), ("V", "V", ""), ("T", "T", ""), ("E", "Extend to Cursor", "")],
default="-",
)
@@ -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:
+49 -29
View File
@@ -893,7 +893,7 @@ class EditObjectUI:
add_layout_hotkey_operator(row, "Extend", "S_E", "", ui_context)
row = cls.layout.row(align=True) if ui_context != "TOOL_HEADER" else row
add_layout_hotkey_operator(
row, "Extend Height", "C_E", "Extend wall height to 3D cursor Z position", ui_context
row, "Extend to Cursor", "C_E", "Extend profile/extrusion to 3D cursor", ui_context
)
row = cls.layout.row(align=True) if ui_context != "TOOL_HEADER" else row
if AuthoringData.data["active_class"] in (
@@ -945,6 +945,11 @@ class EditObjectUI:
add_layout_hotkey_operator(
cls.layout, "Extend To Underside", "S_E", bpy.ops.bim.extend_walls_to_underside.__doc__, ui_context
)
if AuthoringData.data["has_extrusion"]:
row = cls.layout.row(align=True) if ui_context != "TOOL_HEADER" else row
add_layout_hotkey_operator(
row, "Extend to Cursor", "C_E", "Extend extrusion to 3D cursor", ui_context
)
if AuthoringData.data["is_flippable_element"]:
cls.draw_flip(ui_context, row)
@@ -1453,44 +1458,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