From 7f87f1fb89fb001320223a4d85e6267f342bf13c Mon Sep 17 00:00:00 2001 From: Ryan Schultz Date: Sun, 11 Jan 2026 18:43:07 -0600 Subject: [PATCH] fix #7537 - Layer thickness correct when slab is rotated and few other features... - Add dual-rotation support for AXIS3 slabs (IFC angle + object rotation) - Fix profile editing to display horizontal projection for tilted slabs - Fix AXIS2 layer slicing to use local extrusion direction for walls - Fix ChangeExtrusionDepth to refresh geometry after depth changes - Remove rotation lock on slabs to allow free rotation - Fix undefined variable bug in add_slab_representation.py --- src/bonsai/bonsai/bim/module/model/slab.py | 312 ++++++++++++++---- src/bonsai/bonsai/bim/module/model/wall.py | 154 ++++++--- src/bonsai/bonsai/tool/collector.py | 2 - src/bonsai/bonsai/tool/loader.py | 70 +++- .../api/geometry/add_slab_representation.py | 42 ++- .../api/geometry/add_wall_representation.py | 3 +- 6 files changed, 451 insertions(+), 132 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/model/slab.py b/src/bonsai/bonsai/bim/module/model/slab.py index ddc7c1d9a8..dd65f9c1c1 100644 --- a/src/bonsai/bonsai/bim/module/model/slab.py +++ b/src/bonsai/bonsai/bim/module/model/slab.py @@ -35,7 +35,7 @@ import bonsai.core.geometry import bonsai.core.root import bonsai.tool as tool from bonsai.bim.ifc import IfcStore -from math import cos, pi +from math import cos, sin, pi, acos, degrees from mathutils import Vector, Matrix from bonsai.bim.module.model.decorator import ProfileDecorator, PolylineDecorator, ProductDecorator from bonsai.bim.module.model.polyline import PolylineOperator @@ -296,50 +296,65 @@ class DumbSlabPlaner: if representation: extrusion = tool.Model.get_extrusion(representation) if extrusion: - # TODO Right now we don't have a reliable way to calculate the existing x_angle only based solely on the extrusion direction. - # For instances, a 30 degrees angled extrusion with positive direction has the same extrusion direction as a - # -150 degrees angled extrusion with negative direction. The difference lies in the object's rotation. - # This means that things can get messy if the user changes the object x angle somehow. We have to figure out an alternative approach. - existing_x_angle = obj.rotation_euler.x - existing_x_angle = 0 if tool.Cad.is_x(existing_x_angle, 0, tolerance=0.001) else existing_x_angle - existing_x_angle = 0 if tool.Cad.is_x(existing_x_angle, pi, tolerance=0.001) else existing_x_angle - existing_x_angle = 0 if tool.Cad.is_x(existing_x_angle, 2 * pi, tolerance=0.001) else existing_x_angle direction_ratios = Vector(extrusion.ExtrudedDirection.DirectionRatios) - offset_direction = direction_ratios.copy() - perpendicular_depth = thickness * abs(1 / cos(existing_x_angle)) - perpendicular_offset = layer_offset * abs(1 / cos(existing_x_angle)) / self.unit_scale - - # Check angle and z direction to determine whether the extrusion direction is positive or negative - if (abs(existing_x_angle) < (pi / 2) and direction_ratios.z > 0) or ( - abs(existing_x_angle) > (pi / 2) and direction_ratios.z < 0 - ): - # The extrusion direction is positive. If the layer_parameter is set to negative, - # then the we change the extrusion direction. - if layer_params["direction_sense"] == "NEGATIVE": - direction_ratios *= -1 - elif (abs(existing_x_angle) > (pi / 2) and direction_ratios.z > 0) or ( - abs(existing_x_angle) < (pi / 2) and direction_ratios.z < 0 - ): - # The extrusion direction is negative. If the layer_parameter is set to positive, - # then the we change the extrusion direction. And the offset direction should remain positive - # for either direction sense, so we change it. - offset_direction *= -1 - if layer_params["direction_sense"] == "POSITIVE": - direction_ratios *= -1 - - extrusion.ExtrudedDirection.DirectionRatios = tuple(direction_ratios) - extrusion.Depth = perpendicular_depth - - ifc_position = extrusion.Position - position = offset_direction * perpendicular_offset - material = ifcopenshell.util.element.get_material(element) - if material: - if material.is_a("IfcMaterialLayerSetUsage"): - material.OffsetFromReferenceLine = position.z - if ifc_position: - ifc_position.Location.Coordinates = position + + # Calculate the actual extrusion angle from vertical + extrusion_angle = 0 + if direction_ratios.length > 0: + cos_angle = direction_ratios.normalized().dot(Vector((0, 0, 1))) + extrusion_angle = acos(min(max(cos_angle, -1), 1)) + + # FIX: Only apply 1/cos factor when there's actual extrusion slope + if extrusion_angle > 1e-6: + perpendicular_depth = thickness * abs(1 / cos(extrusion_angle)) + perpendicular_offset = layer_offset * abs(1 / cos(extrusion_angle)) / self.unit_scale else: - tool.Model.add_extrusion_position(extrusion, position) + perpendicular_depth = thickness + perpendicular_offset = layer_offset / self.unit_scale + + # Check if direction sense needs to be applied + # This should only happen if explicitly requested, not automatically + if layer_params.get("apply_direction_sense", False): + # Store current direction before potential change + old_direction = direction_ratios.copy() + + # Apply direction sense logic + existing_x_angle = extrusion_angle + if (abs(existing_x_angle) < (pi / 2) and direction_ratios.z > 0) or ( + abs(existing_x_angle) > (pi / 2) and direction_ratios.z < 0 + ): + if layer_params["direction_sense"] == "NEGATIVE": + direction_ratios *= -1 + elif (abs(existing_x_angle) > (pi / 2) and direction_ratios.z > 0) or ( + abs(existing_x_angle) < (pi / 2) and direction_ratios.z < 0 + ): + offset_direction = direction_ratios.copy() * -1 + if layer_params["direction_sense"] == "POSITIVE": + direction_ratios *= -1 + + # If direction changed, update extrusion with rotation compensation + if (direction_ratios.normalized() - old_direction.normalized()).length > 1e-6: + update_extrusion_direction(element, tuple(direction_ratios), obj) + # After updating direction, get the updated extrusion + extrusion = tool.Model.get_extrusion(representation) + + # Update depth + extrusion.Depth = perpendicular_depth + + # Update position + ifc_position = extrusion.Position + if direction_ratios.length > 0: + offset_vector = direction_ratios.normalized() * perpendicular_offset + position = offset_vector + + material = ifcopenshell.util.element.get_material(element) + if material and material.is_a("IfcMaterialLayerSetUsage"): + material.OffsetFromReferenceLine = position.z + + if ifc_position: + ifc_position.Location.Coordinates = position + else: + tool.Model.add_extrusion_position(extrusion, position) else: props = tool.Model.get_model_props() @@ -383,6 +398,113 @@ class DumbSlabPlaner: ) + def update_extrusion_direction(element: ifcopenshell.entity_instance, + new_direction_ratios: tuple, + obj: bpy.types.Object = None) -> None: + """ + Update extrusion direction while preserving overall object orientation. + + Args: + element: The IFC element + new_direction_ratios: New extrusion direction ratios (x,y,z) + obj: Optional Blender object (will be fetched if not provided) + """ + if not obj: + obj = tool.Ifc.get_object(element) + if not obj: + return + + representation = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW") + if not representation: + return + + extrusion = tool.Model.get_extrusion(representation) + if not extrusion: + return + + # Get current extrusion direction + old_direction = Vector(extrusion.ExtrudedDirection.DirectionRatios) + if old_direction.length == 0: + old_direction = Vector((0, 0, 1)) # Default + + new_direction = Vector(new_direction_ratios) + if new_direction.length == 0: + new_direction = Vector((0, 0, 1)) # Default + + # Normalize both directions + old_direction_normalized = old_direction.normalized() + new_direction_normalized = new_direction.normalized() + + # Store current object matrix + old_matrix = obj.matrix_world.copy() + + # Calculate the rotation needed to keep same orientation + # When extrusion direction changes from A to B relative to local coordinates, + # we need to rotate the object by the inverse of that change + + # Calculate rotation from old to new direction + rotation_axis = old_direction_normalized.cross(new_direction_normalized) + if rotation_axis.length > 1e-6: + rotation_axis.normalized() + dot_product = old_direction_normalized.dot(new_direction_normalized) + angle = acos(min(max(dot_product, -1), 1)) + + # Apply INVERSE rotation to object to compensate + rotation_matrix = Matrix.Rotation(-angle, 4, rotation_axis) + + # Update object rotation + obj.matrix_world = old_matrix @ rotation_matrix + bpy.context.view_layer.update() + + # Update extrusion direction (keeping magnitude) + if old_direction.length > 0: + # Preserve the magnitude of the original direction vector + magnitude = old_direction.length + new_direction = new_direction_normalized * magnitude + + extrusion.ExtrudedDirection.DirectionRatios = tuple(new_direction) + + # Update depth based on new extrusion angle + extrusion_angle = 0 + if new_direction.length > 0: + cos_angle = new_direction_normalized.dot(Vector((0, 0, 1))) + extrusion_angle = acos(min(max(cos_angle, -1), 1)) + + # Get current depth (perpendicular depth) + current_perpendicular_depth = extrusion.Depth + + # If we have material layer info, calculate actual thickness + material = ifcopenshell.util.element.get_material(element) + actual_thickness = current_perpendicular_depth + if material and material.is_a("IfcMaterialLayerSetUsage"): + layer_set = material.ForLayerSet + actual_thickness = sum([l.LayerThickness for l in layer_set.MaterialLayers]) + unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) + actual_thickness *= unit_scale + + # Convert to perpendicular depth if needed + if extrusion_angle > 1e-6: + new_perpendicular_depth = actual_thickness * abs(1 / cos(extrusion_angle)) + else: + new_perpendicular_depth = actual_thickness + + extrusion.Depth = new_perpendicular_depth + + # Update position offset if needed + if extrusion.Position: + # Recalculate offset based on new direction + material = ifcopenshell.util.element.get_material(element) + if material and material.is_a("IfcMaterialLayerSetUsage"): + offset = material.OffsetFromReferenceLine + if extrusion_angle > 1e-6: + perpendicular_offset = offset * abs(1 / cos(extrusion_angle)) + else: + perpendicular_offset = offset + + offset_vector = new_direction_normalized * perpendicular_offset + extrusion.Position.Location.Coordinates = tuple(offset_vector) + + class EnableEditingSketchExtrusionProfile(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.enable_editing_sketch_extrusion_profile" bl_label = "Enable Editing Sketch Extrusion Profile" @@ -656,6 +778,8 @@ class EnableEditingExtrusionProfile(bpy.types.Operator, tool.Ifc.Operator): extrusion = tool.Model.get_extrusion(body) existing_x_angle = tool.Model.get_existing_x_angle(extrusion) layer_params = tool.Model.get_material_layer_parameters(element) + + usage_type = tool.Model.get_usage_type(element) if extrusion.Position: position = Matrix(ifcopenshell.util.placement.get_axis2placement(extrusion.Position).tolist()) @@ -669,22 +793,49 @@ class EnableEditingExtrusionProfile(bpy.types.Operator, tool.Ifc.Operator): tranlation_matrix = Matrix.Translation(rot_offset) position = position @ tranlation_matrix - # Restore Object rotation to zero - local_rot_mat = obj.rotation_euler.to_matrix() - rot_mat = Matrix.Rotation(-existing_x_angle, 4, "X") - new_rot_mat = local_rot_mat.to_4x4() @ rot_mat - new_rot_euler = new_rot_mat.to_euler() - obj.rotation_euler = new_rot_euler + # For AXIS3 with dual rotation: Reset rotation to zero so profile is horizontal + if usage_type == "LAYER3": + # Store original rotation for later restoration + original_rotation_x = obj.rotation_euler.x + obj["pre_edit_rotation_x"] = original_rotation_x + + # Reset rotation to zero - profile will be horizontal + current_z_rot = obj.rotation_euler.z + obj.rotation_euler.x = 0.0 + obj.rotation_euler.z = current_z_rot + else: + # Original behavior: Restore Object rotation to zero + local_rot_mat = obj.rotation_euler.to_matrix() + rot_mat = Matrix.Rotation(-existing_x_angle, 4, "X") + new_rot_mat = local_rot_mat.to_4x4() @ rot_mat + new_rot_euler = new_rot_mat.to_euler() + obj.rotation_euler = new_rot_euler else: position = Matrix() - tool.Model.import_profile(extrusion.SweptArea, obj=obj, position=position, x_angle=existing_x_angle) + # Import profile with correct x_angle + if usage_type == "LAYER3": + # For LAYER3: Use x_angle=0 and scale by cos(rotation) to get horizontal projection + obj_x_rotation = original_rotation_x # Use stored original rotation + scale_factor = abs(cos(obj_x_rotation)) if abs(obj_x_rotation) > 1e-6 else 1.0 + + # Import with x_angle=0 + tool.Model.import_profile(extrusion.SweptArea, obj=obj, position=position, x_angle=0) + + # Scale the Y coordinates by cos(rotation) to get horizontal projection + bpy.ops.object.mode_set(mode='OBJECT') + for vert in obj.data.vertices: + vert.co.y *= scale_factor + else: + # For other types: Use existing_x_angle + tool.Model.import_profile(extrusion.SweptArea, obj=obj, position=position, x_angle=existing_x_angle) bpy.ops.object.mode_set(mode="EDIT") ProfileDecorator.install(context, exit_edit_mode_callback=lambda: disable_editing_extrusion_profile(context)) if not bpy.app.background: tool.Blender.set_viewport_tool("bim.cad_tool") + return {"FINISHED"} @@ -706,6 +857,8 @@ class EditExtrusionProfile(bpy.types.Operator, tool.Ifc.Operator): extrusion = tool.Model.get_extrusion(body) existing_x_angle = tool.Model.get_existing_x_angle(extrusion) layer_params = tool.Model.get_material_layer_parameters(element) + usage_type = tool.Model.get_usage_type(element) + if extrusion.Position: position = Matrix(ifcopenshell.util.placement.get_axis2placement(extrusion.Position).tolist()) position.translation *= self.unit_scale @@ -718,20 +871,40 @@ class EditExtrusionProfile(bpy.types.Operator, tool.Ifc.Operator): tranlation_matrix = Matrix.Translation(rot_offset) position = position @ tranlation_matrix - # Restore Object rotation to x_angle - local_rot_mat = obj.rotation_euler.to_matrix() - rot_mat = Matrix.Rotation(existing_x_angle, 4, "X") - new_rot_mat = local_rot_mat.to_4x4() @ rot_mat - new_rot_euler = new_rot_mat.to_euler() - obj.rotation_euler = new_rot_euler + # Restore rotation + if usage_type == "LAYER3": + # Restore original rotation from before editing + if "pre_edit_rotation_x" in obj: + current_z_rot = obj.rotation_euler.z + obj.rotation_euler.x = obj["pre_edit_rotation_x"] + obj.rotation_euler.z = current_z_rot + del obj["pre_edit_rotation_x"] + else: + # Original behavior + local_rot_mat = obj.rotation_euler.to_matrix() + rot_mat = Matrix.Rotation(existing_x_angle, 4, "X") + new_rot_mat = local_rot_mat.to_4x4() @ rot_mat + new_rot_euler = new_rot_mat.to_euler() + obj.rotation_euler = new_rot_euler else: position = Matrix() - profile = tool.Model.export_profile(obj, position=position, x_angle=existing_x_angle) + # Export profile with correct x_angle + if usage_type == "LAYER3": + # Scale Y coordinates back up before exporting + obj_x_rotation = obj.rotation_euler.x + scale_factor = abs(cos(obj_x_rotation)) if abs(obj_x_rotation) > 1e-6 else 1.0 + + # Un-scale the profile before exporting + for vert in obj.data.vertices: + vert.co.y /= scale_factor # Inverse of import scaling + + profile = tool.Model.export_profile(obj, position=position, x_angle=0) + else: + profile = tool.Model.export_profile(obj, position=position, x_angle=existing_x_angle) if not profile: - def msg(self, context): self.layout.label(text="INVALID PROFILE") @@ -781,6 +954,29 @@ class EditExtrusionProfile(bpy.types.Operator, tool.Ifc.Operator): ) + footprint_context = ifcopenshell.util.representation.get_context( + tool.Ifc.get(), "Plan", "FootPrint", "SKETCH_VIEW" + ) + if not footprint_context: + return + + curves = [profile.OuterCurve] + if profile.is_a("IfcArbitraryProfileDefWithVoids"): + curves.extend(profile.InnerCurves) + new_footprint = ifcopenshell.api.geometry.add_footprint_representation( + tool.Ifc.get(), context=footprint_context, curves=curves + ) + old_footprint = ifcopenshell.util.representation.get_representation(element, "Plan", "FootPrint", "SKETCH_VIEW") + if old_footprint: + for inverse in tool.Ifc.get().get_inverse(old_footprint): + ifcopenshell.util.element.replace_attribute(inverse, old_footprint, new_footprint) + bonsai.core.geometry.remove_representation(tool.Ifc, tool.Geometry, obj=obj, representation=old_footprint) + else: + ifcopenshell.api.geometry.assign_representation( + tool.Ifc.get(), product=element, representation=new_footprint + ) + + class ResetVertex(bpy.types.Operator): bl_idname = "bim.reset_vertex" bl_label = "Reset Vertex" diff --git a/src/bonsai/bonsai/bim/module/model/wall.py b/src/bonsai/bonsai/bim/module/model/wall.py index 5b858dec83..8557ae6ccb 100644 --- a/src/bonsai/bonsai/bim/module/model/wall.py +++ b/src/bonsai/bonsai/bim/module/model/wall.py @@ -43,7 +43,7 @@ import bonsai.core.geometry import bonsai.core.model as core import bonsai.tool as tool from bonsai.bim.ifc import IfcStore -from math import pi, sin, cos, degrees, atan2 +from math import pi, sin, cos, degrees, atan2, acos from mathutils import Vector, Matrix from bonsai.bim.module.model.opening import FilledOpeningGenerator from bonsai.bim.module.model.decorator import PolylineDecorator, ProductDecorator @@ -397,27 +397,46 @@ class ChangeExtrusionDepth(bpy.types.Operator, tool.Ifc.Operator): for obj in selected_objs: element = tool.Ifc.get_entity(obj) assert element + representation = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW") if not representation: continue + extrusion = tool.Model.get_extrusion(representation) if not extrusion: continue + + # Get extrusion direction x, y, z = extrusion.ExtrudedDirection.DirectionRatios + + # Calculate angle from vertical x_angle = Vector((0, 1)).angle_signed(Vector((y, z))) - extrusion.Depth = self.depth / si_conversion * (1 / cos(x_angle)) + + # For sloped walls, compensate so VERTICAL height = target depth + cos_angle = cos(x_angle) + compensation_factor = abs(1 / cos_angle) if abs(cos_angle) > 1e-6 else 1.0 + new_depth_ifc = (self.depth / si_conversion) * compensation_factor + + extrusion.Depth = new_depth_ifc + + # IMPORTANT: Refresh the geometry to reflect the IFC changes + bonsai.core.geometry.switch_representation( + tool.Ifc, + tool.Geometry, + obj=obj, + representation=representation, + ) + if tool.Model.get_usage_type(element) == "LAYER2": for rel in element.ConnectedFrom: if rel.is_a() == "IfcRelConnectsElements": - ifcopenshell.api.geometry.disconnect_element( - ifc_file, - relating_element=rel.RelatingElement, - related_element=element, - ) - layer2_objs.append(obj) + related_element = rel.RelatedElement + if related_element.is_a() == "IfcWall": + layer2_objs.append(tool.Ifc.get_object(related_element)) if layer2_objs: tool.Model.recalculate_walls(layer2_objs) + return {"FINISHED"} @@ -437,80 +456,126 @@ class ChangeExtrusionXAngle(bpy.types.Operator, tool.Ifc.Operator): def _execute(self, context): layer2_objs: list[bpy.types.Object] = [] - x_angle = 0 if tool.Cad.is_x(self.x_angle, 0, tolerance=0.001) else self.x_angle - x_angle = 0 if tool.Cad.is_x(self.x_angle, pi, tolerance=0.001) else self.x_angle - unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) - selected_objs = tool.Model.get_selected_mesh_ifc_objects() builder = ifcopenshell.util.shape_builder.ShapeBuilder(tool.Ifc.get()) + unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) + x_angle = self.x_angle - for obj in selected_objs: + for obj in context.selected_objects: element = tool.Ifc.get_entity(obj) - assert element + if not element: + continue + representation = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW") if not representation: continue extrusion = tool.Model.get_extrusion(representation) if not extrusion: continue + + # Get current object rotation matrix + obj_rotation = obj.matrix_world.to_3x3() + + # Get current extrusion direction in LOCAL coordinates + current_local_direction = Vector(extrusion.ExtrudedDirection.DirectionRatios) + if current_local_direction.length == 0: + current_local_direction = Vector((0, 0, 1)) + current_local_direction_normalized = current_local_direction.normalized() + + # Calculate what the current extrusion direction is in WORLD coordinates + current_world_direction = obj_rotation @ current_local_direction_normalized + existing_x_angle = tool.Model.get_existing_x_angle(extrusion) existing_x_angle = 0 if tool.Cad.is_x(existing_x_angle, 0, tolerance=0.001) else existing_x_angle existing_x_angle = 0 if tool.Cad.is_x(existing_x_angle, pi, tolerance=0.001) else existing_x_angle + + # Calculate the NEW local extrusion direction based on x_angle + new_local_direction = Vector((0.0, sin(x_angle), cos(x_angle))) + + # Check if extrusion direction is actually changing + current_local_norm = current_local_direction_normalized + new_local_norm = new_local_direction.normalized() + + # Compare the LOCAL directions + local_direction_changed = (new_local_norm - current_local_norm).length > 1e-6 + if tool.Model.get_usage_type(element) == "LAYER2": - x, y, z = extrusion.ExtrudedDirection.DirectionRatios depth = extrusion.Depth / abs(1 / cos(existing_x_angle)) perpendicular_depth = depth * abs(1 / cos(x_angle)) - extrusion.ExtrudedDirection.DirectionRatios = (0.0, sin(x_angle), cos(x_angle)) - layer2_objs.append(obj) + + # Update extrusion direction + if local_direction_changed: + extrusion.ExtrudedDirection.DirectionRatios = tuple(new_local_direction) + + # Always update depth extrusion.Depth = perpendicular_depth + layer2_objs.append(obj) + else: if tool.Model.get_usage_type(element) == "LAYER3": - existing_x_angle = obj.rotation_euler.x - existing_x_angle = 0 if tool.Cad.is_x(existing_x_angle, 0, tolerance=0.001) else existing_x_angle - existing_x_angle = 0 if tool.Cad.is_x(existing_x_angle, pi, tolerance=0.001) else existing_x_angle + # For slabs, handle polyline scaling + existing_obj_x_angle = obj.rotation_euler.x + existing_obj_x_angle = 0 if tool.Cad.is_x(existing_obj_x_angle, 0, tolerance=0.001) else existing_obj_x_angle + existing_obj_x_angle = 0 if tool.Cad.is_x(existing_obj_x_angle, pi, tolerance=0.001) else existing_obj_x_angle + # Scale the polyline coordinates coord_list = builder.get_polyline_coords(extrusion.SweptArea.OuterCurve) coord_list = [ (p[0], p[1] * abs(cos(existing_x_angle))) for p in coord_list - ] # Reset the transformation and returns to the original points with 0 degrees + ] # Reset the transformation coord_list = [ (p[0], p[1] * abs(1 / cos(x_angle))) for p in coord_list ] # Apply the transformation for the new x_angle builder.set_polyline_coords(extrusion.SweptArea.OuterCurve, coord_list) - # The extrusion direction calculated previously default to the positive direction - # Here we set the extrusion direction to negative if that's the case - direction_ratios = Vector((0.0, sin(x_angle), cos(x_angle))) - # direction_ratios = Vector(extrusion.ExtrudedDirection.DirectionRatios) + # Calculate new extrusion direction with direction sense + base_local_direction = Vector((0.0, sin(x_angle), cos(x_angle))) layer_params = tool.Model.get_material_layer_parameters(element) perpendicular_depth = layer_params["thickness"] * abs(1 / cos(x_angle)) / unit_scale perpendicular_offset = layer_params["offset"] * abs(1 / cos(x_angle)) / unit_scale - offset_direction = direction_ratios.copy() + offset_direction = base_local_direction.copy() - # Check angle and z direction to determine whether the extrusion direction is positive or negative - if (abs(x_angle) < (pi / 2) and direction_ratios.z > 0) or ( - abs(x_angle) > (pi / 2) and direction_ratios.z < 0 + # Apply direction sense + final_local_direction = base_local_direction.copy() + if (abs(x_angle) < (pi / 2) and base_local_direction.z > 0) or ( + abs(x_angle) > (pi / 2) and base_local_direction.z < 0 ): - # The extrusion direction is positive. If the layer_parameter is set to negative, - # then the we change the extrusion direction. if layer_params["direction_sense"] == "NEGATIVE": - direction_ratios *= -1 - elif ((x_angle) > (pi / 2) and direction_ratios.z > 0) or ( - (x_angle) < (pi / 2) and direction_ratios.z < 0 + final_local_direction *= -1 + elif (x_angle > (pi / 2) and base_local_direction.z > 0) or ( + x_angle < (pi / 2) and base_local_direction.z < 0 ): - # The extrusion direction is negative. If the layer_parameter is set to positive, - # then the we change the extrusion direction. - # then the we change the extrusion direction. And the offset direction should remain positive - # for either direction sense, so we change it. offset_direction *= -1 if layer_params["direction_sense"] == "POSITIVE": - direction_ratios *= -1 - - extrusion.ExtrudedDirection.DirectionRatios = tuple(direction_ratios) + final_local_direction *= -1 + + # Check if extrusion direction actually changed + final_local_norm = final_local_direction.normalized() + local_direction_changed = (final_local_norm - current_local_norm).length > 1e-6 + + # Update extrusion properties + extrusion.ExtrudedDirection.DirectionRatios = tuple(final_local_direction) extrusion.Depth = perpendicular_depth if extrusion.Position or perpendicular_offset != 0: position = offset_direction * perpendicular_offset tool.Model.add_extrusion_position(extrusion, position) + + # Adjust object rotation if extrusion direction changed + if local_direction_changed: + # Calculate what the NEW world direction would be with current object rotation + expected_new_world_direction = obj_rotation @ final_local_norm + + # The rotation needed is from expected_new_world_direction to current_world_direction + rotation_axis = expected_new_world_direction.cross(current_world_direction) + if rotation_axis.length > 1e-6: + rotation_axis.normalize() + dot_product = expected_new_world_direction.dot(current_world_direction) + angle = acos(min(max(dot_product, -1), 1)) + + # Create and apply rotation matrix + rotation_matrix = Matrix.Rotation(angle, 4, rotation_axis) + obj.matrix_world = rotation_matrix @ obj.matrix_world + bpy.context.view_layer.update() bonsai.core.geometry.switch_representation( tool.Ifc, @@ -519,12 +584,6 @@ class ChangeExtrusionXAngle(bpy.types.Operator, tool.Ifc.Operator): representation=representation, ) - # Object rotation - current_z_rot = obj.rotation_euler.z - rot_mat = mathutils.Matrix.Rotation(x_angle, 4, "X") - obj.rotation_euler = rot_mat.to_euler() - obj.rotation_euler.z = current_z_rot - if layer2_objs: tool.Model.recalculate_walls(layer2_objs) return {"FINISHED"} @@ -1022,6 +1081,7 @@ class DumbWallGenerator: obj=obj, representation=representation, ) + pset = ifcopenshell.api.pset.add_pset(self.file, product=element, name="EPset_Parametric") ifcopenshell.api.pset.edit_pset(self.file, pset=pset, properties={"Engine": "Bonsai.DumbLayer2"}) material = ifcopenshell.util.element.get_material(element) diff --git a/src/bonsai/bonsai/tool/collector.py b/src/bonsai/bonsai/tool/collector.py index e91c52d3d0..7c46135b26 100644 --- a/src/bonsai/bonsai/tool/collector.py +++ b/src/bonsai/bonsai/tool/collector.py @@ -44,8 +44,6 @@ class Collector(bonsai.core.tool.Collector): # Note that tool.Geometry.is_locked is only checked within the if # statements for efficiency as it is a slow check. tool.Geometry.lock_scale(obj) - if element.is_a("IfcSlab"): - tool.Geometry.lock_rotation(obj, x=True) if element.is_a("IfcGridAxis"): if tool.Geometry.is_locked(element): diff --git a/src/bonsai/bonsai/tool/loader.py b/src/bonsai/bonsai/tool/loader.py index e28ceacaec..89603372d0 100644 --- a/src/bonsai/bonsai/tool/loader.py +++ b/src/bonsai/bonsai/tool/loader.py @@ -1030,28 +1030,57 @@ class Loader(bonsai.core.tool.Loader): sense_factor = 1 else: return mesh + if len(layer_set.MaterialLayers) == 1: return mesh + bm = bmesh.new() bm.from_mesh(mesh) + prev_co = None + advance_direction = None # Will store direction to advance planes + if not usage: - sense_factor = 1 # Assume the extrusion vector points in the direction sense + sense_factor = 1 no = cls.get_extrusion_vector(element).normalized() co = Vector((0.0, 0.0, offset)) + advance_direction = no elif usage.LayerSetDirection == "AXIS2": co = Vector((0.0, offset, 0.0)) - no = cls.get_extrusion_vector(element).normalized() - no = no.cross(Vector([1.0, 0.0, 0.0])) + + # Get LOCAL extrusion direction + local_extrusion = Vector([0.0, 0.0, 1.0]) + if body := ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW"): + for item in ifcopenshell.util.representation.resolve_representation(body).Items: + while item.is_a("IfcBooleanResult"): + item = item.FirstOperand + if item.is_a("IfcExtrudedAreaSolid"): + local_extrusion = Vector(item.ExtrudedDirection.DirectionRatios).normalized() + break + + # Thickness direction: perpendicular to extrusion and length + thickness_dir = local_extrusion.cross(Vector([1.0, 0.0, 0.0])).normalized() + + # Ensure it points in POSITIVE Y (through wall thickness, not backwards) + if thickness_dir.y < 0: + thickness_dir = -thickness_dir + + no = thickness_dir + advance_direction = thickness_dir elif usage.LayerSetDirection == "AXIS3": co = Vector((0.0, 0.0, offset)) no = cls.get_extrusion_vector(element).normalized() no = Vector([0.0, 0.0, 1.0]) + advance_direction = no elif usage.LayerSetDirection == "AXIS1": co = Vector((0.0, 0.0, offset)) no = cls.get_extrusion_vector(element).normalized() no = Vector([1.0, 0.0, 0.0]) + advance_direction = no + no *= sense_factor + advance_direction *= sense_factor + # Cache this body = ifcopenshell.util.representation.get_context(tool.Ifc.get(), "Model", "Body", "MODEL_VIEW") styles = {} @@ -1059,20 +1088,25 @@ class Loader(bonsai.core.tool.Loader): for i, material in enumerate(mesh.materials): if style := tool.Ifc.get_entity(material): styles[style] = i + last_i = len(layer_set.MaterialLayers) - 1 for i, layer in enumerate(layer_set.MaterialLayers): if i != last_i: prev_co = co.copy() - co += no * layer.LayerThickness * cls.unit_scale + # Use advance_direction (not no) to move planes! + co += advance_direction * layer.LayerThickness * cls.unit_scale + bisect_geom = bmesh.ops.bisect_plane( bm, geom=bm.verts[:] + bm.edges[:] + bm.faces[:], dist=0.0001, plane_co=co, plane_no=no ) bmesh.ops.duplicate(bm, geom=bisect_geom["geom_cut"]) + if not (style := ifcopenshell.util.representation.get_material_style(layer.Material, body)): continue if (material_index := styles.get(style, None)) is None: material_index = len(mesh.materials) mesh.materials.append(tool.Ifc.get_object(style)) + if i == last_i: for face in bisect_geom["geom"]: if isinstance(face, bmesh.types.BMFace): @@ -1097,13 +1131,35 @@ class Loader(bonsai.core.tool.Loader): return mesh @classmethod - def get_extrusion_vector(cls, wall): - if body := ifcopenshell.util.representation.get_representation(wall, "Model", "Body", "MODEL_VIEW"): + def get_extrusion_vector(cls, element): + """Get the extrusion direction in WORLD coordinates (accounting for object rotation)""" + if body := ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW"): for item in ifcopenshell.util.representation.resolve_representation(body).Items: while item.is_a("IfcBooleanResult"): item = item.FirstOperand if item.is_a("IfcExtrudedAreaSolid"): - return Vector(item.ExtrudedDirection.DirectionRatios) + local_direction = Vector(item.ExtrudedDirection.DirectionRatios) + + # Transform to world coordinates using object rotation + obj = tool.Ifc.get_object(element) + if obj: + # Apply object rotation to get actual world direction + world_direction = obj.matrix_world.to_3x3() @ local_direction + return world_direction + + return local_direction + return Vector([0.0, 0.0, 1.0]) + + @classmethod + def get_local_extrusion_vector(cls, element): + """Get the extrusion direction in LOCAL coordinates (from IFC, no object rotation)""" + if body := ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW"): + for item in ifcopenshell.util.representation.resolve_representation(body).Items: + while item.is_a("IfcBooleanResult"): + item = item.FirstOperand + if item.is_a("IfcExtrudedAreaSolid"): + local_direction = Vector(item.ExtrudedDirection.DirectionRatios) + return local_direction return Vector([0.0, 0.0, 1.0]) @classmethod diff --git a/src/ifcopenshell-python/ifcopenshell/api/geometry/add_slab_representation.py b/src/ifcopenshell-python/ifcopenshell/api/geometry/add_slab_representation.py index 284f6e7c30..28025c6624 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/geometry/add_slab_representation.py +++ b/src/ifcopenshell-python/ifcopenshell/api/geometry/add_slab_representation.py @@ -100,35 +100,45 @@ class Usecase: size = self.convert_si_to_unit(1) points = ((0.0, 0.0), (size, 0.0), (size, size), (0.0, size), (0.0, 0.0)) if self.polyline: - points = [ - (self.convert_si_to_unit(p[0]), self.convert_si_to_unit(p[1] * abs(1 / cos(self.x_angle)))) - for p in self.polyline - ] + # Only scale polyline if we have actual slope + if self.x_angle and abs(self.x_angle) > 1e-6: + points = [ + (self.convert_si_to_unit(p[0]), self.convert_si_to_unit(p[1] * abs(1 / cos(self.x_angle)))) + for p in self.polyline + ] + else: + points = [ + (self.convert_si_to_unit(p[0]), self.convert_si_to_unit(p[1])) + for p in self.polyline + ] + if self.file.schema == "IFC2X3": curve = self.file.createIfcPolyline([self.file.createIfcCartesianPoint(p) for p in points]) else: curve = self.file.createIfcIndexedPolyCurve(self.file.createIfcCartesianPointList2D(points)) - + if self.x_angle: direction_ratios = (0.0, sin(self.x_angle), cos(self.x_angle)) else: direction_ratios = (0.0, 0.0, 1.0) - offset_direction = direction_ratios # offset direction doesn't change if direction_sense is negative extrusion_direction = self.file.createIfcDirection(direction_ratios) - if self.direction_sense == "NEGATIVE": - direction_ratios = tuple(-n for n in direction_ratios) - extrusion_direction = self.file.createIfcDirection(direction_ratios) - - perpendicular_offset = self.convert_si_to_unit(self.offset) * abs(1 / cos(self.x_angle)) - perpendicular_depth = self.convert_si_to_unit(self.depth) * abs(1 / cos(self.x_angle)) + + # Calculate depth based on extrusion angle + extrusion_angle = abs(self.x_angle) if self.x_angle else 0 + if extrusion_angle > 1e-6: + perpendicular_depth = self.convert_si_to_unit(self.depth) * abs(1 / cos(extrusion_angle)) + perpendicular_offset = self.convert_si_to_unit(self.offset) * abs(1 / cos(extrusion_angle)) + else: + perpendicular_depth = self.convert_si_to_unit(self.depth) + perpendicular_offset = self.convert_si_to_unit(self.offset) + position = None - # default position for IFC2X3 where .Position is not optional if self.file.schema == "IFC2X3" or self.offset != 0: position_vector = ( - offset_direction[0] * perpendicular_offset, - offset_direction[1] * perpendicular_offset, - offset_direction[2] * perpendicular_offset, + direction_ratios[0] * perpendicular_offset, + direction_ratios[1] * perpendicular_offset, + direction_ratios[2] * perpendicular_offset, ) position = self.file.createIfcAxis2Placement3D( self.file.createIfcCartesianPoint(position_vector), diff --git a/src/ifcopenshell-python/ifcopenshell/api/geometry/add_wall_representation.py b/src/ifcopenshell-python/ifcopenshell/api/geometry/add_wall_representation.py index 4c108dc7df..12a033429d 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/geometry/add_wall_representation.py +++ b/src/ifcopenshell-python/ifcopenshell/api/geometry/add_wall_representation.py @@ -85,7 +85,6 @@ class Usecase: def create_item(self) -> ifcopenshell.entity_instance: length = self.convert_si_to_unit(self.settings["length"]) thickness = self.convert_si_to_unit(self.settings["thickness"]) - thickness *= 1 / cos(self.settings["x_angle"]) if self.settings["direction_sense"] == "NEGATIVE": thickness *= -1 points = ( @@ -113,7 +112,7 @@ class Usecase: self.file.createIfcDirection((1.0, 0.0, 0.0)), ), extrusion_direction, - self.convert_si_to_unit(self.settings["height"]) * abs(1 / cos(self.settings["x_angle"])), + self.convert_si_to_unit(self.settings["height"]), ) if self.settings["booleans"]: extrusion = self.apply_booleans(extrusion)