diff --git a/src/bonsai/bonsai/bim/module/geometry/ui.py b/src/bonsai/bonsai/bim/module/geometry/ui.py index 68e3e0fa47..2d66077f4e 100644 --- a/src/bonsai/bonsai/bim/module/geometry/ui.py +++ b/src/bonsai/bonsai/bim/module/geometry/ui.py @@ -66,6 +66,7 @@ def object_menu(self, context): self.layout.operator("bim.override_object_delete", icon="PLUGIN") self.layout.operator("bim.override_paste_buffer", icon="PLUGIN") self.layout.menu("BIM_MT_object_set_origin", icon="PLUGIN") + self.layout.operator("bim.align_local_x_to_length", icon="PLUGIN") self.layout.menu("BIM_MT_separate", icon="PLUGIN") # only show the create instance operator if the current tool is the BIM tool diff --git a/src/bonsai/bonsai/bim/module/model/__init__.py b/src/bonsai/bonsai/bim/module/model/__init__.py index d59cd6d260..4236689584 100644 --- a/src/bonsai/bonsai/bim/module/model/__init__.py +++ b/src/bonsai/bonsai/bim/module/model/__init__.py @@ -122,6 +122,7 @@ classes = ( wall.OffsetWalls, wall.RecalculateWall, wall.RotateWall90, + wall.AlignLocalXToLength, wall.SplitWall, wall.SplitWallAtCursor, wall.DisconnectElements, diff --git a/src/bonsai/bonsai/bim/module/model/wall.py b/src/bonsai/bonsai/bim/module/model/wall.py index 5b73f5641b..6ca6057f0f 100644 --- a/src/bonsai/bonsai/bim/module/model/wall.py +++ b/src/bonsai/bonsai/bim/module/model/wall.py @@ -2754,6 +2754,74 @@ class RotateWall90(bpy.types.Operator, tool.Ifc.Operator): return {"FINISHED"} +class AlignLocalXToLength(bpy.types.Operator, tool.Ifc.Operator): + bl_idname = "bim.align_local_x_to_length" + bl_label = "Align Local X To Length" + bl_description = ( + "Rotate the selected element's local axes 90° about Z so its longer horizontal " + "footprint side lies along local +X, without changing its world position or " + "appearance.\n\n" + "Window/door snapping and material layer offsets assume local +X carries the " + "wall length. Run this on a mesh you converted to a wall whose long side ended " + "up on local Y, so those tools use the correct axis. Only freeform (non-parametric) " + "elements are affected; a square footprint has no unambiguous long side and is left " + "as-is." + ) + bl_options = {"REGISTER", "UNDO"} + + @classmethod + def poll(cls, context): + if not tool.Model.has_selected_ifc_objects(): + cls.poll_message_set("No IFC objects selected.") + return False + return True + + def _execute(self, context: bpy.types.Context) -> set[str]: + aligned = 0 + skipped = 0 + for obj in tool.Blender.get_selected_objects(): + element = tool.Ifc.get_entity(obj) + if element is None or not isinstance(obj.data, bpy.types.Mesh): + continue + # Parametric elements (LAYER2/LAYER3/PROFILE) are regenerated from the IFC + # axis, where local +X is guaranteed to be the length. Never rewrite their + # mesh; there is nothing to fix and doing so would fight the parametric engine. + if tool.Model.get_usage_type(element) is not None: + skipped += 1 + continue + if obj.data.users > 1: + self.report( + {"WARNING"}, + f"Object '{obj.name}' shares its mesh with other objects; " + "make it single-user before aligning its local axes.", + ) + skipped += 1 + continue + x_extent, y_extent = tool.Model.get_local_horizontal_extents(obj) + if y_extent <= x_extent * (1.0 + 1e-4): + # Already aligned (long side on X) or an ambiguous square footprint. + skipped += 1 + continue + + # Rotate the mesh data so local +Y maps onto local +X, then compensate the + # object matrix by the inverse so the wall keeps its exact world placement + # and appearance. This is an explicit, user-invoked re-framing of the local + # axes, not an automatic override of the user's modelling. + rotation = Matrix.Rotation(math.radians(-90.0), 4, "Z") + obj.data.transform(rotation) + obj.matrix_world = obj.matrix_world @ rotation.inverted() + context.view_layer.update() + with context.temp_override(active_object=obj, selected_objects=[obj]): + bpy.ops.bim.update_representation(obj=obj.name) + aligned += 1 + + if aligned: + self.report({"INFO"}, f"Aligned local X to length on {aligned} element(s).") + else: + self.report({"INFO"}, "No elements needed aligning (long side already on local X, or square).") + return {"FINISHED"} + + def _wall_axis_world_segment_from_geom(obj: bpy.types.Object, geom: dict) -> tuple[Vector, Vector]: """Compose the world-space axis segment from an already-read ``geom`` dict. Used by the billboarding gizmo groups so a single cached IFC read drives both diff --git a/src/bonsai/bonsai/bim/module/root/operator.py b/src/bonsai/bonsai/bim/module/root/operator.py index 0a585eb918..eb49a58e3d 100644 --- a/src/bonsai/bonsai/bim/module/root/operator.py +++ b/src/bonsai/bonsai/bim/module/root/operator.py @@ -390,6 +390,26 @@ class AssignClass(bpy.types.Operator, tool.Ifc.Operator): pset = ifcopenshell.api.pset.add_pset(ifc_file, product=element, name="BBIM_ImportedBlenderProps") ifcopenshell.api.pset.edit_pset(ifc_file, pset=pset, properties=custom_props) + # Window/door snapping and material layer offsets assume a wall's local +X + # carries its length. A mesh authored with its long side on local Y keeps + # those axes, so those tools use the wrong axis (see #7453). We do not touch + # the user's geometry, but we surface the actionable fix. + if ( + element + and element.is_a("IfcWall") + and isinstance(obj.data, bpy.types.Mesh) + and tool.Model.get_usage_type(element) is None + ): + x_extent, y_extent = tool.Model.get_local_horizontal_extents(obj) + if y_extent > x_extent * (1.0 + 1e-4): + self.report( + {"WARNING"}, + f"Wall '{obj.name}' is longer along local Y than local X. " + "Window/door snapping and layer offsets assume the length is on local X. " + "Run Object > IFC Set Origin > Align Local X To Length (bim.align_local_x_to_length) to fix it " + "without changing the wall's appearance.", + ) + # TODO: reload representation might lead to the object being replaced by object of the other type. # We probably should track it somehow and keep the original selection. diff --git a/src/bonsai/bonsai/core/tool.py b/src/bonsai/bonsai/core/tool.py index 51120ad088..d09fe8fb9e 100644 --- a/src/bonsai/bonsai/core/tool.py +++ b/src/bonsai/bonsai/core/tool.py @@ -700,6 +700,7 @@ class Model: def get_material_layer_parameters(cls, element): pass def get_slab_clipping_bmesh(cls, obj): pass def get_usage_type(cls, element): pass + def get_local_horizontal_extents(cls, obj): pass def get_wall_axis(cls, obj, layers=None): pass def import_curve(cls, curve, obj=None, position=None): pass def import_profile(cls, profile, obj=None, position=None): pass diff --git a/src/bonsai/bonsai/tool/model.py b/src/bonsai/bonsai/tool/model.py index a24a328df1..9316f55f5f 100644 --- a/src/bonsai/bonsai/tool/model.py +++ b/src/bonsai/bonsai/tool/model.py @@ -1082,6 +1082,19 @@ class Model(bonsai.core.tool.Model): elif material.is_a("IfcMaterialProfileSet"): return "PROFILE" + @classmethod + def get_local_horizontal_extents(cls, obj: bpy.types.Object) -> tuple[float, float]: + """``(x_extent, y_extent)`` of ``obj.bound_box`` in the object's local frame. + + Axis-based consumers (``get_wall_axis``, window/door snapping, layer offsets) + treat local +X as the wall length and local Y as its thickness. Comparing + these two extents tells whether a freeform (mesh-converted) wall has its long + side on local Y instead of X, which is what swaps effective length/thickness + for those consumers (see #7453).""" + xs = [v[0] for v in obj.bound_box] + ys = [v[1] for v in obj.bound_box] + return (max(xs) - min(xs), max(ys) - min(ys)) + @classmethod def get_wall_axis( cls, obj: bpy.types.Object, layers: Optional[MaterialLayerParameters] = None