Compare commits

...

2 Commits

Author SHA1 Message Date
Ryan Schultz 7c738c15d9 Fix slab layer geometry for rotated and angled slabs
Three bugs fixed:

1. EditAssignedMaterial was sweeping all slabs that share a layer set
   when changing material properties, instead of updating only the
   selected element's assigned material.

2. slice_layerset_mesh (loader.py): layer bisect positions were scaled
   incorrectly due to a stale unit_scale and a reversed/missing
   DirectionSense guard. Now always recalculates unit_scale fresh and
   correctly applies sense_factor.

3. change_thickness (slab.py): extrusion depth was computed as
   `thickness / cos(obj.rotation_euler.x)`, which incorrectly scaled
   ObjectPlacement-rotated slabs (where the extrusion direction is
   local Z and no scaling is needed). The correct formula is
   `thickness / extrusion_vec.z`, which handles both ObjectPlacement
   rotation (extrusion_vec.z ≈ 1.0 → no scale) and ExtrudedDirection
   tilts (extrusion_vec.z < 1.0 → scale up) uniformly. The resulting
   slab was 1.414× too thick for 45°-rotated slabs, making both
   material layers appear fatter than specified.

   slice_layerset_mesh retains a depth_scale safety factor
   (extrusion_vec.z × ifc_depth / total_layer_thickness) as a
   robustness guard for IFC files from other authoring tools where
   extrusion depth may not match the sum of LayerThicknesses.
2026-03-14 12:21:54 -05:00
Ryan Schultz 036ee098a6 Fix slab layer geometry: custom offset isolation, unit scale, and layer ordering
Three related bugs fixed in the slab material layer set workflow:

1. EditAssignedMaterial (operator.py): Applying a custom offset to one slab
   instance incorrectly regenerated geometry for ALL slabs sharing the same
   IfcMaterialLayerSet. Replaced regenerate_from_layer_set (sweeps all users)
   with per-element regenerate_from_occurence for AXIS3 slabs and targeted
   recalculate_walls for AXIS2 walls. Each element's IfcMaterialLayerSetUsage
   attributes are now updated individually before regeneration.

2. slice_layerset_mesh (loader.py): Loader.unit_scale is a class variable only
   set during full file import, so it was stale (= 1) during live geometry
   updates on foot-based IFC files. Layer bisect planes were being computed in
   IFC feet while the mesh was in Blender metres, placing all cuts completely
   outside the mesh. Fixed by computing unit_scale fresh from the IFC file on
   each call via ifcopenshell.util.unit.calculate_unit_scale.

3. OffsetFromReferenceLine stale / layer order reversed (slab.py, loader.py):
   A guard (and custom_offset is None) in change_thickness prevented writing
   the correct OffsetFromReferenceLine (position.z) to the usage when a custom
   offset was active. This left the value at 0.0 instead of the actual slab
   bottom (e.g. -1.0 IFC units for a TOP-reference slab), so the bisect
   starting point co was at the reference plane rather than the slab bottom,
   reversing layer assignments or missing layers entirely. Removed the guard —
   safe because each element has its own IfcMaterialLayerSetUsage instance.
   Reverted the AXIS3 bisect normal back to (0,0,1) (upward from co at slab
   bottom) which is correct once OffsetFromReferenceLine is properly set.
2026-03-14 12:21:35 -05:00
3 changed files with 55 additions and 16 deletions
@@ -614,25 +614,29 @@ class EditAssignedMaterial(bpy.types.Operator, tool.Ifc.Operator):
attributes=attributes,
)
layer_sets_to_regenerate = set()
slab_planer = slab.DumbSlabPlaner()
wall_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":
wall_objs.append(obj)
if wall_objs:
tool.Model.recalculate_walls(wall_objs)
if material_set_usage.is_a("IfcMaterialProfileSetUsage"):
if "CardinalPoint" in attributes:
+13 -3
View File
@@ -277,8 +277,19 @@ class DumbSlabPlaner:
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 (
@@ -301,7 +312,6 @@ class DumbSlabPlaner:
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:
+31 -6
View File
@@ -1063,12 +1063,16 @@ class Loader(bonsai.core.tool.Loader):
@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
else:
return mesh
@@ -1077,14 +1081,32 @@ class Loader(bonsai.core.tool.Loader):
bm = bmesh.new()
bm.from_mesh(mesh)
prev_co = None
depth_scale = 1.0
if 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]))
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.
# For well-formed IFC data, the mesh local Z span equals total_perp_thickness
# (extrusion.Depth is always set to thickness / extrusion_vec.z so that
# extrusion.Depth × extrusion_vec.z = thickness). depth_scale is kept as
# a safety net for IFC files from other authoring tools where the extrusion
# depth may not exactly match the sum of LayerThicknesses.
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)
elif usage.LayerSetDirection == "AXIS1":
co = Vector((0.0, 0.0, offset))
no = cls.get_extrusion_vector(element).normalized()
@@ -1101,7 +1123,7 @@ class Loader(bonsai.core.tool.Loader):
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
)
@@ -1115,14 +1137,17 @@ class Loader(bonsai.core.tool.Loader):
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