mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-09 17:31:45 +00:00
Bonsai: surface and explicitly fix walls whose length is on local Y
get_wall_axis and every consumer of it (window and door snapping, material layer offsets, wall joins) assume a wall's local +X carries its length. A mesh converted to IfcWall keeps its authored axes, so a box modelled long on local Y gets its effective length and thickness swapped for those tools, which is the snapping failure in issue 7453. An earlier attempt rotated the mesh during conversion and was closed: overriding explicit user modelling breaks unpredictable cases such as heritage, industrial and dilapidation models. Nothing is changed automatically here. assign_class only reports an actionable warning naming the affected tools and the fix, and a new bim.align_local_x_to_length operator rotates the local axes only when the user invokes it, compensating the object matrix so the world position and appearance stay identical. Parametric walls, square footprints and multi user meshes are left untouched. Generated with the assistance of an AI coding tool.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -122,6 +122,7 @@ classes = (
|
||||
wall.OffsetWalls,
|
||||
wall.RecalculateWall,
|
||||
wall.RotateWall90,
|
||||
wall.AlignLocalXToLength,
|
||||
wall.SplitWall,
|
||||
wall.SplitWallAtCursor,
|
||||
wall.DisconnectElements,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user