Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 17 additions & 13 deletions src/bonsai/bonsai/bim/module/material/operator.py
Original file line number Diff line number Diff line change
Expand Up @@ -386,8 +386,8 @@ def _execute(self, context):
layer_set=layer_set,
material=tool.Ifc.get().by_id(int(omprops.material)),
)
slab.DumbSlabPlaner().regenerate_from_layer_set(layer_set)
wall.DumbWallPlaner().regenerate_from_layer_set(layer_set)
slab.Layer3Planer().regenerate_from_layer_set(layer_set)
wall.Layer2Planer().regenerate_from_layer_set(layer_set)


class ReorderMaterialSetItem(bpy.types.Operator, tool.Ifc.Operator):
Expand Down Expand Up @@ -425,8 +425,8 @@ def _execute(self, context):
return {"CANCELLED"}
ifcopenshell.api.material.remove_layer(tool.Ifc.get(), layer=layer)
for material_set in material_sets:
slab.DumbSlabPlaner().regenerate_from_layer_set(material_set)
wall.DumbWallPlaner().regenerate_from_layer_set(material_set)
slab.Layer3Planer().regenerate_from_layer_set(material_set)
wall.Layer2Planer().regenerate_from_layer_set(material_set)


class DuplicateLayer(bpy.types.Operator, tool.Ifc.Operator):
Expand Down Expand Up @@ -615,25 +615,29 @@ def _execute(self, context):
attributes=attributes,
)

layer_sets_to_regenerate = set()
slab_planer = slab.Layer3Planer()
axis2_objs = []

for obj in objects:
obj_element = tool.Ifc.get_entity(obj)
obj_material_usage = ifcopenshell.util.element.get_material(obj_element)

if obj_material_usage and obj_material_usage.is_a("IfcMaterialLayerSetUsage"):
obj_material_usage.OffsetFromReferenceLine = material.OffsetFromReferenceLine
obj_material_usage.DirectionSense = material.DirectionSense
obj_material_usage.ReferenceExtent = material.ReferenceExtent

layer_sets_to_regenerate.add(obj_material_usage.ForLayerSet)

# Save custom offset to BBIM_MaterialLayer pset
tool.Model.save_custom_offset_to_pset(obj_element, obj)

for layer_set in layer_sets_to_regenerate:
wall.DumbWallPlaner().regenerate_from_layer_set(layer_set)
slab.DumbSlabPlaner().regenerate_from_layer_set(layer_set)
# Targeted regeneration: only update this element's geometry, not
# all elements sharing the layer set (which would corrupt unrelated instances).
if obj_material_usage.LayerSetDirection == "AXIS3":
slab_planer.regenerate_from_occurence(obj_element, obj_material_usage)
elif obj_material_usage.LayerSetDirection == "AXIS2":
axis2_objs.append(obj)

if axis2_objs:
tool.Model.recalculate_layer2_elements(axis2_objs)

if material_set_usage.is_a("IfcMaterialProfileSetUsage"):
if "CardinalPoint" in attributes:
Expand Down Expand Up @@ -781,8 +785,8 @@ def _execute(self, context):
attributes=attributes,
material=self.file.by_id(int(props.material_set_item_material)),
)
slab.DumbSlabPlaner().regenerate_from_layer(layer)
wall.DumbWallPlaner().regenerate_from_layer(layer)
slab.Layer3Planer().regenerate_from_layer(layer)
wall.Layer2Planer().regenerate_from_layer(layer)
elif material.is_a("IfcMaterialProfileSet"):
profile_def = None
if mprops.profiles:
Expand Down
2 changes: 1 addition & 1 deletion src/bonsai/bonsai/bim/module/model/profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -948,7 +948,7 @@ def _execute(self, context):
obj.matrix_world @= rotate_matrix
bpy.context.view_layer.update()
DumbProfileRecalculator().recalculate(profile_objs)
tool.Model.recalculate_walls(layer2_objs)
tool.Model.recalculate_layer2_elements(layer2_objs)
return {"FINISHED"}


Expand Down
22 changes: 16 additions & 6 deletions src/bonsai/bonsai/bim/module/model/slab.py
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,7 @@ def create_slab(self):
return obj


class DumbSlabPlaner:
class Layer3Planer:
def regenerate_from_layer_set_usage(self, usecase_path, ifc_file, settings):
self.unit_scale = ifcopenshell.util.unit.calculate_unit_scale(ifc_file)
obj = bpy.context.active_object
Expand Down Expand Up @@ -281,8 +281,19 @@ def change_thickness(self, element: ifcopenshell.entity_instance, thickness: flo
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
# The extrusion depth needed to achieve a given perpendicular thickness depends on
# how much the extrusion direction deviates from the slab face normal (local Z).
# For an ObjectPlacement-rotated slab, extrusion_vec.z ≈ 1.0 → no scaling.
# For an ExtrudedDirection-tilted slab, extrusion_vec.z < 1.0 → scale up.
extrusion_z = abs(direction_ratios.normalized().z)
if extrusion_z > 1e-6:
perpendicular_depth = thickness / extrusion_z
perpendicular_offset = layer_offset / extrusion_z / self.unit_scale
else:
perpendicular_depth = thickness
perpendicular_offset = layer_offset / self.unit_scale

ifc_position = extrusion.Position

# 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 (
Expand All @@ -305,7 +316,6 @@ def change_thickness(self, element: ifcopenshell.entity_instance, thickness: flo
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:
Expand Down Expand Up @@ -891,7 +901,7 @@ def create_slab_from_polyline(self, context):
usage=material_set_usage,
attributes=attributes,
)
DumbSlabPlaner().regenerate_from_occurence(element, material_set_usage)
Layer3Planer().regenerate_from_occurence(element, material_set_usage)

def modal(self, context, event):
return IfcStore.execute_ifc_operator(self, context, event, method="MODAL")
Expand Down Expand Up @@ -993,5 +1003,5 @@ def _execute(self, context):
if rel.is_a() == "IfcRelConnectsElements" and rel.RelatedElement.is_a("IfcWall"):
walls.append(tool.Ifc.get_object(rel.RelatedElement))

tool.Model.recalculate_walls(walls)
tool.Model.recalculate_layer2_elements(walls)
return {"FINISHED"}
12 changes: 6 additions & 6 deletions src/bonsai/bonsai/bim/module/model/wall.py
Original file line number Diff line number Diff line change
Expand Up @@ -372,7 +372,7 @@ def poll(cls, context):

def _execute(self, context):
objects = tool.Model.get_selected_mesh_ifc_objects()
tool.Model.recalculate_walls(objects)
tool.Model.recalculate_layer2_elements(objects)
return {"FINISHED"}


Expand Down Expand Up @@ -420,7 +420,7 @@ def _execute(self, context):
layer2_objs.append(obj)

if layer2_objs:
tool.Model.recalculate_walls(layer2_objs)
tool.Model.recalculate_layer2_elements(layer2_objs)
return {"FINISHED"}


Expand Down Expand Up @@ -529,7 +529,7 @@ def _execute(self, context):
obj.rotation_euler.z = current_z_rot

if layer2_objs:
tool.Model.recalculate_walls(layer2_objs)
tool.Model.recalculate_layer2_elements(layer2_objs)
return {"FINISHED"}


Expand Down Expand Up @@ -641,7 +641,7 @@ def create_walls_from_polyline(self, context: bpy.types.Context) -> Union[set[st
# if material.is_a("IfcMaterialLayerSetUsage"):
attributes = {"OffsetFromReferenceLine": offset, "DirectionSense": direction_sense}
ifcopenshell.api.material.edit_layer_usage(model, usage=material_set_usage, attributes=attributes)
tool.Model.recalculate_walls([wall["obj"]])
tool.Model.recalculate_layer2_elements([wall["obj"]])

if walls:
if is_polyline_closed:
Expand Down Expand Up @@ -1034,7 +1034,7 @@ def get_relating_type_class(self, relating_type: ifcopenshell.entity_instance) -
return next(c for c in classes if "StandardCase" not in c)


class DumbWallPlaner:
class Layer2Planer:
def regenerate_from_layer(self, layer: ifcopenshell.entity_instance) -> None:
for layer_set in layer.ToMaterialLayerSet:
self.regenerate_from_layer_set(layer_set)
Expand All @@ -1055,7 +1055,7 @@ def regenerate_from_layer_set(self, layer_set: ifcopenshell.entity_instance) ->
else:
for rel in inverse.AssociatedTo:
walls.extend([tool.Ifc.get_object(e) for e in rel.RelatedObjects])
tool.Model.recalculate_walls([w for w in set(walls) if w])
tool.Model.recalculate_layer2_elements([w for w in set(walls) if w])


class DumbWallJoiner:
Expand Down
2 changes: 1 addition & 1 deletion src/bonsai/bonsai/core/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ def offset_walls(ifc: type[tool.Ifc], blender: type[tool.Blender], model: type[t
]
for obj in objs:
model.offset_wall(obj, offset_type)
model.recalculate_walls(objs)
model.recalculate_layer2_elements(objs)


def align_walls(
Expand Down
4 changes: 2 additions & 2 deletions src/bonsai/bonsai/core/type.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,9 @@ def assign_type(
if (usage := model.get_usage_type(type)) == "PROFILE":
model.regenerate_profile(obj)
elif usage == "LAYER2":
model.recalculate_walls([obj])
model.recalculate_layer2_elements([obj])
elif usage == "LAYER3":
model.regenerate_slab(obj)
model.regenerate_layer3_element(obj)
else:
type_data = type_tool.get_object_data(ifc.get_object(type))
if type_data:
Expand Down
54 changes: 48 additions & 6 deletions src/bonsai/bonsai/tool/loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -1063,12 +1063,16 @@ def convert_geometry_to_mesh(

@classmethod
def slice_layerset_mesh(cls, element: ifcopenshell.entity_instance, mesh: bpy.types.Mesh) -> bpy.types.Mesh:
# Always compute unit_scale fresh — cls.unit_scale may be stale (e.g. during live
# geometry updates that don't go through the full import pipeline).
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())

if not (material := ifcopenshell.util.element.get_material(element)):
return mesh
elif material.is_a("IfcMaterialLayerSetUsage"):
usage = material
layer_set = material.ForLayerSet
offset = usage.OffsetFromReferenceLine * cls.unit_scale
offset = usage.OffsetFromReferenceLine * unit_scale
sense_factor = 1 if usage.DirectionSense == "POSITIVE" else -1
elif material.is_a("IfcMaterialLayerSet"):
usage = None
Expand All @@ -1082,6 +1086,7 @@ def slice_layerset_mesh(cls, element: ifcopenshell.entity_instance, mesh: bpy.ty
bm = bmesh.new()
bm.from_mesh(mesh)
prev_co = None
depth_scale = 1.0
if not usage:
sense_factor = 1 # Assume the extrusion vector points in the direction sense
no = cls.get_extrusion_vector(element).normalized()
Expand All @@ -1091,9 +1096,43 @@ def slice_layerset_mesh(cls, element: ifcopenshell.entity_instance, mesh: bpy.ty
no = cls.get_extrusion_vector(element).normalized()
no = no.cross(Vector([1.0, 0.0, 0.0]))
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])
co = Vector((0.0, 0.0, offset))
# Bisect planes are always horizontal (world Z) for AXIS3.
# The mesh local Z span should equal total_perp_thickness × unit_scale:
# extrusion.Depth × extrusion_vec.z = total_perp_thickness
# If the IFC data is inconsistent (e.g. from old Bonsai code that incorrectly
# scaled extrusion.Depth for ObjectPlacement-rotated slabs), we self-heal:
# scale the mesh vertices to the correct Z span and fix the stored depth so
# future imports load correctly without this correction.
extrusion_vec = cls.get_extrusion_vector(element).normalized()
ifc_extrusion_depth = None
if body_rep := ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW"):
for item in ifcopenshell.util.representation.resolve_representation(body_rep).Items:
while item.is_a("IfcBooleanResult"):
item = item.FirstOperand
if item.is_a("IfcExtrudedAreaSolid"):
ifc_extrusion_depth = item.Depth
break
total_perp_thickness = sum(l.LayerThickness for l in layer_set.MaterialLayers)
if ifc_extrusion_depth and total_perp_thickness:
depth_scale = abs(extrusion_vec.z) * (ifc_extrusion_depth / total_perp_thickness)
if abs(depth_scale - 1.0) > 1e-6 and ifc_extrusion_depth and body_rep:
# Z_span / depth_scale == total_perp_thickness × unit_scale for any
# extrusion direction, so scaling from the mesh bottom is always correct.
min_z = min(v.co.z for v in bm.verts)
for v in bm.verts:
v.co.z = min_z + (v.co.z - min_z) / depth_scale
# Fix the IFC data so future imports don't require this correction.
extrusion_vec_z = abs(extrusion_vec.z)
correct_depth = total_perp_thickness / extrusion_vec_z if extrusion_vec_z > 1e-6 else total_perp_thickness
for item in ifcopenshell.util.representation.resolve_representation(body_rep).Items:
while item.is_a("IfcBooleanResult"):
item = item.FirstOperand
if item.is_a("IfcExtrudedAreaSolid"):
item.Depth = correct_depth
break
depth_scale = 1.0
elif usage.LayerSetDirection == "AXIS1":
co = Vector((0.0, 0.0, offset))
no = cls.get_extrusion_vector(element).normalized()
Expand All @@ -1110,7 +1149,7 @@ def slice_layerset_mesh(cls, element: ifcopenshell.entity_instance, mesh: bpy.ty
for i, layer in enumerate(layer_set.MaterialLayers):
if i != last_i:
prev_co = co.copy()
co += no * layer.LayerThickness * cls.unit_scale
co += no * layer.LayerThickness * depth_scale * 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
)
Expand All @@ -1124,14 +1163,17 @@ def slice_layerset_mesh(cls, element: ifcopenshell.entity_instance, mesh: bpy.ty
for face in bisect_geom["geom"]:
if isinstance(face, bmesh.types.BMFace):
center = face.calc_center_median()
if (center - co).dot(no) >= 0:
dot = (center - co).dot(no)
if dot >= 0:
face.material_index = material_index
has_layer_styles = True
else:
for face in bisect_geom["geom"]:
if isinstance(face, bmesh.types.BMFace):
center = face.calc_center_median()
if (center - co).dot(no) < 0 and (center - prev_co).dot(no) >= 0:
dot_co = (center - co).dot(no)
dot_prev = (center - prev_co).dot(no)
if dot_co < 0 and dot_prev >= 0:
face.material_index = material_index
has_layer_styles = True

Expand Down
42 changes: 21 additions & 21 deletions src/bonsai/bonsai/tool/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -2717,41 +2717,41 @@ def recreate_wall(cls, element: ifcopenshell.entity_instance, obj: bpy.types.Obj
tool.Geometry.record_object_position(obj)

@classmethod
def recalculate_walls(cls, walls: list[bpy.types.Object]) -> None:
def recalculate_layer2_elements(cls, objs: list[bpy.types.Object]) -> None:
queue: set[tuple[ifcopenshell.entity_instance, bpy.types.Object]] = set()
for wall in walls:
element = tool.Ifc.get_entity(wall)
if tool.Ifc.is_moved(wall):
bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=wall)
queue.add((element, wall))
for obj in objs:
element = tool.Ifc.get_entity(obj)
if tool.Ifc.is_moved(obj):
bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=obj)
queue.add((element, obj))
for rel in getattr(element, "ConnectedTo", []):
obj = tool.Ifc.get_object(rel.RelatedElement)
if tool.Ifc.is_moved(obj):
bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=obj)
queue.add((rel.RelatedElement, obj))
connected_obj = tool.Ifc.get_object(rel.RelatedElement)
if tool.Ifc.is_moved(connected_obj):
bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=connected_obj)
queue.add((rel.RelatedElement, connected_obj))
for rel in getattr(element, "ConnectedFrom", []):
obj = tool.Ifc.get_object(rel.RelatingElement)
if tool.Ifc.is_moved(obj):
bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=obj)
queue.add((rel.RelatingElement, obj))
for element, wall in queue:
if tool.Model.get_usage_type(element) == "LAYER2" and wall:
connected_obj = tool.Ifc.get_object(rel.RelatingElement)
if tool.Ifc.is_moved(connected_obj):
bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=connected_obj)
queue.add((rel.RelatingElement, connected_obj))
for element, obj in queue:
if tool.Model.get_usage_type(element) == "LAYER2" and obj:
# Use layer custom offset
custom_offset = tool.Model.get_material_layer_custom_offset(element, wall)
custom_offset = tool.Model.get_material_layer_custom_offset(element, obj)
material = ifcopenshell.util.element.get_material(element)
if material.is_a("IfcMaterialLayerSetUsage") and custom_offset is not None:
material.OffsetFromReferenceLine = custom_offset

cls.recreate_wall(element, wall)
cls.recreate_wall(element, obj)

@classmethod
def regenerate_slab(cls, obj: bpy.types.Object) -> None:
from bonsai.bim.module.model.slab import DumbSlabPlaner
def regenerate_layer3_element(cls, obj: bpy.types.Object) -> None:
from bonsai.bim.module.model.slab import Layer3Planer

element = tool.Ifc.get_entity(obj)
material_set = ifcopenshell.util.element.get_material(element, should_skip_usage=True)
new_thickness = sum([l.LayerThickness for l in material_set.MaterialLayers])
DumbSlabPlaner().change_thickness(element, new_thickness)
Layer3Planer().change_thickness(element, new_thickness)

@classmethod
def regenerate_profile(cls, obj: bpy.types.Object) -> None:
Expand Down
Loading