Fix #7531: Add BBIM_MaterialLayer pset for custom offset persistence and UI improvements

This commit introduces a new BBIM_MaterialLayer property set to persist custom
material layer offset settings in IFC files, along with significant UI improvements
for material editing.

Features Added:
- New BBIM_MaterialLayer pset with properties:
  - UseCustomOffset (bool): Toggle for custom offset
  - CustomOffset (float): Offset value in SI units
  - CustomWallReference (str): Wall reference point (EXTERIOR/CENTER/INTERIOR)
  - CustomSlabReference (str): Slab reference point (TOP/MIDDLE/BOTTOM)

Tool Updates (tool.py):
- Added save_custom_offset_to_pset(): Saves custom offset from UI props to pset
- Added load_custom_offset_from_pset(): Loads custom offset from pset to UI props
- Updated get_material_layer_custom_offset(): Reads from pset when props unavailable

Operator Updates (operator.py):
- EnableEditingAssignedMaterial: Loads custom offset from pset on edit start
- EditAssignedMaterial: Saves custom offset to pset on edit completion
- Fixed KeyError for CardinalPoint in material constituent sets

Data Layer (data.py):
- Added bbim_material_layer_pset() to ObjectMaterialData for caching pset data
- Improves performance by avoiding repeated IFC queries during UI drawing

UI Improvements (ui.py):
- Added custom offset display in both editable and read-only material UIs
- Added OffsetFromReferenceLine display in read-only UI
- Implemented dynamic headers based on material type (Layers/Profiles/Constituents)
- Improved visual hierarchy with consistent boxing and indentation
- Aligned editable and read-only UI layouts for consistency
- Fixed layer set boundary labels (Top/Bottom for slabs, Interior/Exterior for walls)
- Reorganized "Add Material" section into material layers box

Bug Fixes:
- Fixed format_distance() to correctly handle negative imperial values
  (e.g., -0.5' now displays as "-0' - 6"" instead of "0' - -6"")

This allows users to set custom material layer offsets that persist in the IFC
file and remain available across sessions, with clear visual feedback in both
editing and viewing modes.
This commit is contained in:
Ryan Schultz
2026-01-04 13:15:39 -06:00
parent a17b0604e1
commit 831c3190cc
5 changed files with 287 additions and 70 deletions
@@ -313,15 +313,13 @@ def format_distance(
if not feet and not add_inches:
tx_dist += str(feet) + "'"
# Add "0' - " when we have inches but no feet
# But only add " - " separator if we actually have inches to show
if not feet and add_inches:
tx_dist += "0' - "
if value < 0:
tx_dist += "-0' - "
else:
tx_dist += "0' - "
elif feet and add_inches:
tx_dist += " - "
if not feet and value < 0:
tx_dist += "-"
if add_inches:
if feet == 0 and inches == 0 and not frac:
# Special case: exactly zero, show "0"
@@ -176,6 +176,7 @@ class ObjectMaterialData:
cls.data["active_material_constituents"] = cls.active_material_constituents()
# after material_name and type_material
cls.data["is_type_material_overridden"] = cls.is_type_material_overridden()
cls.data["bbim_material_layer_pset"] = cls.bbim_material_layer_pset()
cls.is_loaded = True
@@ -426,3 +427,35 @@ class ObjectMaterialData:
# so we check occurrence material explicitly
occurrence_material = ifcopenshell.util.element.get_material(cls.element, should_inherit=False)
return bool(occurrence_material)
@classmethod
def bbim_material_layer_pset(cls) -> Union[dict[str, Any], None]:
"""Load BBIM_MaterialLayer pset data for display in UI."""
if not cls.element:
return None
pset_data = ifcopenshell.util.element.get_pset(cls.element, "BBIM_MaterialLayer")
if not pset_data or not pset_data.get("UseCustomOffset", False):
return None
# Keep offset in SI units - format_distance will handle conversion
custom_offset_si = pset_data.get("CustomOffset", 0.0)
# Get the appropriate reference based on usage type
usage_type = tool.Model.get_usage_type(cls.element)
custom_reference = None
reference_label = None
if usage_type == "LAYER2":
custom_reference = pset_data.get("CustomWallReference", "")
reference_label = "Wall Reference"
elif usage_type == "LAYER3":
custom_reference = pset_data.get("CustomSlabReference", "")
reference_label = "Slab Reference"
return {
"use_custom_offset": pset_data.get("UseCustomOffset", False),
"custom_offset": custom_offset_si, # Store in SI units
"custom_reference": custom_reference,
"reference_label": reference_label,
}
@@ -509,6 +509,10 @@ class EnableEditingAssignedMaterial(bpy.types.Operator):
bonsai.bim.helper.import_attributes(material[0], props.material_set_attributes)
else:
bonsai.bim.helper.import_attributes(material, props.material_set_attributes)
# Load custom offset from BBIM_MaterialLayer pset
tool.Model.load_custom_offset_from_pset(element, obj)
return {"FINISHED"}
def import_attributes_callback(
@@ -621,13 +625,17 @@ class EditAssignedMaterial(bpy.types.Operator, tool.Ifc.Operator):
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)
if material_set_usage.is_a("IfcMaterialProfileSetUsage"):
attributes["CardinalPoint"] = int(attributes["CardinalPoint"])
if "CardinalPoint" in attributes:
attributes["CardinalPoint"] = int(attributes["CardinalPoint"])
ifcopenshell.api.material.edit_profile_usage(
self.file,
usage=material_set_usage,
+138 -44
View File
@@ -20,6 +20,8 @@ from __future__ import annotations
import bonsai.bim.helper
import bonsai.tool as tool
import bpy
import ifcopenshell.util.element
import ifcopenshell.util.unit
from bpy.types import Panel, UIList
from bonsai.bim.helper import draw_attributes
from bonsai.bim.helper import prop_with_search
@@ -228,31 +230,47 @@ class BIM_PT_object_material(Panel):
self.draw_read_only_set_ui()
def draw_editable_set_ui(self):
bonsai.bim.helper.draw_attributes(self.props.material_set_attributes, self.layout)
bonsai.bim.helper.draw_attributes(self.props.material_set_usage_attributes, self.layout)
# Material Set Attributes Section
row = self.layout.row(align=True)
box = row.box()
bonsai.bim.helper.draw_attributes(self.props.material_set_attributes, box)
bonsai.bim.helper.draw_attributes(self.props.material_set_usage_attributes, box)
# Custom Offset Section
self.draw_custom_offset()
if ObjectMaterialData.data["set_item_name"] == "profile" and not self.mprops.profiles:
row = self.layout.row(align=True)
row.label(text="No Profiles Available")
row.operator("bim.add_profile_def", icon="ADD", text="")
else:
layout = self.layout
layout.separator()
layout.separator()
row = self.layout.row(align=True)
if ObjectMaterialData.data["set_item_name"] == "profile":
prop_with_search(row, self.mprops, "profiles", icon="ITALIC", text="")
prop_with_search(row, self.props, "material", icon="MATERIAL", text="")
op = row.operator(f"bim.add_{ObjectMaterialData.data['set_item_name']}", icon="ADD", text="")
setattr(op, f"{ObjectMaterialData.data['set_item_name']}_set", ObjectMaterialData.data["set"]["id"])
# Dynamic header based on material set type
set_item_name = ObjectMaterialData.data["set_item_name"]
header_map = {
"layer": "Material Layers",
"profile": "Material Profiles",
"constituent": "Material Constituents",
"list_item": "Material List Items"
}
header_text = header_map.get(set_item_name, "Material Items")
self.layout.label(text=header_text)
total_items = len(ObjectMaterialData.data["set_items"])
layout = self.layout
box = layout.box()
row = self.layout.row(align=True)
box = row.box()
# Add Material Section (at the top of this box)
if ObjectMaterialData.data["set_item_name"] == "profile" and not self.mprops.profiles:
box_row = box.row(align=True)
box_row.label(text="No Profiles Available")
box_row.operator("bim.add_profile_def", icon="ADD", text="")
else:
box_row = box.row(align=True)
if ObjectMaterialData.data["set_item_name"] == "profile":
prop_with_search(box_row, self.mprops, "profiles", icon="ITALIC", text="")
prop_with_search(box_row, self.props, "material", icon="MATERIAL", text="")
op = box_row.operator(f"bim.add_{ObjectMaterialData.data['set_item_name']}", icon="ADD", text="")
setattr(op, f"{ObjectMaterialData.data['set_item_name']}_set", ObjectMaterialData.data["set"]["id"])
active_object = bpy.context.active_object
self.layerset_bounds(box, active_object, location="Top_Exterior")
self.layerset_bounds(box, active_object, location="Top_Interior")
if not ObjectMaterialData.data["set_items"]:
row = box.row()
@@ -269,7 +287,7 @@ class BIM_PT_object_material(Panel):
else:
self.draw_read_only_set_item_ui(box, set_item)
self.layerset_bounds(box, active_object, location="Bottom_Interior")
self.layerset_bounds(box, active_object, location="Bottom_Exterior")
def draw_editable_set_item_profile_ui(self, box, set_item):
# box = self.layout.box()
@@ -335,29 +353,98 @@ class BIM_PT_object_material(Panel):
setattr(op, f"{ObjectMaterialData.data['set_item_name']}_index", set_item["index"])
def draw_read_only_set_ui(self):
# Material Set Information Section
row = self.layout.row(align=True)
box = row.box()
if ObjectMaterialData.data["material_class"] != "IfcMaterialList":
row = self.layout.row(align=True)
box_row = box.row(align=True)
set_name = ObjectMaterialData.data["set"]["name"]
row.label(text="Name")
row.label(text=set_name)
box_row.label(text="Name")
box_row.label(text=set_name)
if value := ObjectMaterialData.data["set"]["description"]:
row = self.layout.row(align=True)
row.label(text="Description")
row.label(text=value)
box_row = box.row(align=True)
box_row.label(text="Description")
box_row.label(text=value)
if ObjectMaterialData.data["material_class"] == "IfcMaterialProfileSetUsage":
if value := ObjectMaterialData.data["set_usage"].get("cardinal_point"):
row = self.layout.row(align=True)
row.label(text="Cardinal Point")
row.label(text=value)
box_row = box.row(align=True)
box_row.label(text="Cardinal Point")
box_row.label(text=value)
if ObjectMaterialData.data["total_thickness"]:
row = self.layout.row(align=True)
row.label(text="Total Thickness*")
row.label(text=ObjectMaterialData.data["total_thickness"])
box_row = box.row(align=True)
box_row.label(text="Total Thickness*")
box_row.label(text=ObjectMaterialData.data["total_thickness"])
box = self.layout.box()
# Display OffsetFromReferenceLine for layer sets
if "Layer" in ObjectMaterialData.data["material_class"]:
obj = bpy.context.active_object
if obj:
element = tool.Ifc.get_entity(obj)
if element:
material = ifcopenshell.util.element.get_material(element)
if material and material.is_a("IfcMaterialLayerSetUsage"):
offset_value = material.OffsetFromReferenceLine
# Format the offset value
unit_system = bpy.context.scene.unit_settings.system
prefs = tool.Blender.get_addon_preferences()
precision = None
if unit_system == "IMPERIAL":
precision = prefs.doc.imperial_precision
from bonsai.bim.module.drawing.helper import format_distance
formatted_offset = format_distance(
offset_value, precision=precision, suppress_zero_inches=True, in_unit_length=True
)
box_row = box.row(align=True)
box_row.label(text="Offset From Reference Line")
box_row.label(text=formatted_offset)
# BBIM_MaterialLayer Pset Section
if pset_data := ObjectMaterialData.data.get("bbim_material_layer_pset"):
self.layout.label(text="BBIM_MaterialLayer Pset")
row = self.layout.row(align=True)
box = row.box()
# Custom Offset value - format using format_distance
unit_system = bpy.context.scene.unit_settings.system
prefs = tool.Blender.get_addon_preferences()
precision = None
if unit_system == "IMPERIAL":
precision = prefs.doc.imperial_precision
from bonsai.bim.module.drawing.helper import format_distance
formatted_custom_offset = format_distance(
pset_data['custom_offset'], precision=precision, suppress_zero_inches=True, in_unit_length=True
)
box_row = box.row(align=True)
box_row.label(text="Custom Offset")
box_row.label(text=formatted_custom_offset)
# Reference (if exists)
if pset_data["custom_reference"]:
box_row = box.row(align=True)
box_row.label(text=pset_data["reference_label"])
box_row.label(text=pset_data["custom_reference"])
# Dynamic header based on material set type
set_item_name = ObjectMaterialData.data.get("set_item_name")
if set_item_name:
header_map = {
"layer": "Material Layers",
"profile": "Material Profiles",
"constituent": "Material Constituents",
"list_item": "Material List Items"
}
header_text = header_map.get(set_item_name, "Material Items")
else:
header_text = "Materials"
self.layout.label(text=header_text)
row = self.layout.row(align=True)
box = row.box()
active_object = bpy.context.active_object
self.layerset_bounds(box, active_object, location="Top_Interior")
@@ -403,20 +490,27 @@ class BIM_PT_object_material(Panel):
set_usage = ObjectMaterialData.data.get("set_usage", {})
layer_set_direction = set_usage.get("layer_set_direction")
if layer_set_direction:
box = self.layout.box()
row = box.row(align=True)
row.prop(self.props, "use_custom_offset", text="Use Custom Offset")
row = box.row(align=True)
row = self.layout.row(align=True)
row.label(text="BBIM_MaterialLayer Pset")
# Add indentation with a row that has a separator
row = self.layout.row(align=True)
# row.separator(factor=2.0) # Adjust factor for more/less indent
box = row.box()
box_row = box.row(align=True)
box_row.prop(self.props, "use_custom_offset", text="Use Custom Offset")
box_row = box.row(align=True)
if layer_set_direction == "AXIS2":
row.prop(self.props, "custom_wall_reference", text="Reference")
row.enabled = self.props.use_custom_offset
box_row.prop(self.props, "custom_wall_reference", text="Reference")
box_row.enabled = self.props.use_custom_offset
if layer_set_direction == "AXIS3":
row.prop(self.props, "custom_slab_reference", text="Reference")
row.enabled = self.props.use_custom_offset
box_row.prop(self.props, "custom_slab_reference", text="Reference")
box_row.enabled = self.props.use_custom_offset
row = box.row(align=True)
row.prop(self.props, "custom_offset", text="Custom Offset")
row.enabled = self.props.use_custom_offset
box_row = box.row(align=True)
box_row.prop(self.props, "custom_offset", text="Custom Offset")
box_row.enabled = self.props.use_custom_offset
class BIM_UL_materials(UIList):
+103 -19
View File
@@ -620,6 +620,72 @@ class Model(bonsai.core.tool.Model):
if not openings[i].obj:
openings.remove(i)
@classmethod
def save_custom_offset_to_pset(cls, element: ifcopenshell.entity_instance, obj: bpy.types.Object) -> None:
"""Save custom offset settings to BBIM_MaterialLayer pset."""
props = tool.Material.get_object_material_props(obj)
if not props.use_custom_offset:
# Remove pset if custom offset is disabled
pset = ifcopenshell.util.element.get_pset(element, "BBIM_MaterialLayer")
if pset:
pset_entity = tool.Ifc.get().by_id(pset["id"])
ifcopenshell.api.pset.remove_pset(tool.Ifc.get(), product=element, pset=pset_entity)
return
# Determine which reference to save based on usage type
usage_type = tool.Model.get_usage_type(element)
custom_wall_reference = None
custom_slab_reference = None
if usage_type == "LAYER2":
custom_wall_reference = props.custom_wall_reference
elif usage_type == "LAYER3":
custom_slab_reference = props.custom_slab_reference
# Get or create pset
pset_data = ifcopenshell.util.element.get_pset(element, "BBIM_MaterialLayer")
if pset_data:
pset = tool.Ifc.get().by_id(pset_data["id"])
else:
pset = ifcopenshell.api.pset.add_pset(tool.Ifc.get(), product=element, name="BBIM_MaterialLayer")
# Save properties (store in SI units)
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
properties = {
"UseCustomOffset": props.use_custom_offset,
"CustomOffset": props.custom_offset / unit_scale,
"CustomWallReference": custom_wall_reference if custom_wall_reference else "",
"CustomSlabReference": custom_slab_reference if custom_slab_reference else "",
}
ifcopenshell.api.pset.edit_pset(tool.Ifc.get(), pset=pset, properties=properties)
@classmethod
def load_custom_offset_from_pset(cls, element: ifcopenshell.entity_instance, obj: bpy.types.Object) -> None:
"""Load custom offset settings from BBIM_MaterialLayer pset."""
pset = ifcopenshell.util.element.get_pset(element, "BBIM_MaterialLayer")
if not pset:
return
props = tool.Material.get_object_material_props(obj)
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
# Load properties
props.use_custom_offset = pset.get("UseCustomOffset", False)
props.custom_offset = pset.get("CustomOffset", 0.0) * unit_scale # Convert from SI
# Load the appropriate reference based on usage type
usage_type = tool.Model.get_usage_type(element)
if usage_type == "LAYER2":
custom_wall_ref = pset.get("CustomWallReference", "")
if custom_wall_ref:
props.custom_wall_reference = custom_wall_ref
elif usage_type == "LAYER3":
custom_slab_ref = pset.get("CustomSlabReference", "")
if custom_slab_ref:
props.custom_slab_reference = custom_slab_ref
class MaterialLayerParameters(TypedDict):
"""Float values are in project units."""
@@ -652,13 +718,33 @@ class Model(bonsai.core.tool.Model):
)
@classmethod
def get_material_layer_custom_offset(cls, element: ifcopenshell.entity_instance, obj) -> MaterialLayerParameters:
def get_material_layer_custom_offset(cls, element: ifcopenshell.entity_instance, obj: bpy.types.Object) -> Optional[float]:
"""Get custom offset value, reading from pset if props are not set."""
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
layer_params = tool.Model.get_material_layer_parameters(element)
layer_offset = layer_params["offset"]
thickness = layer_params["thickness"] / unit_scale
props = tool.Material.get_object_material_props(obj)
if props.use_custom_offset:
# Try to load from pset if not already in props
if not props.use_custom_offset:
pset = ifcopenshell.util.element.get_pset(element, "BBIM_MaterialLayer")
if pset and pset.get("UseCustomOffset", False):
# Load from pset
custom_offset = pset.get("CustomOffset", 0.0)
usage_type = tool.Model.get_usage_type(element)
if usage_type == "LAYER2":
custom_offset_reference = pset.get("CustomWallReference", "CENTER")
elif usage_type == "LAYER3":
custom_offset_reference = pset.get("CustomSlabReference", "MIDDLE")
else:
return None
else:
return None
else:
# Use current props
custom_offset = props.custom_offset / unit_scale
if tool.Model.get_usage_type(element) == "LAYER2":
custom_offset_reference = props.custom_wall_reference
elif tool.Model.get_usage_type(element) == "LAYER3":
@@ -666,24 +752,22 @@ class Model(bonsai.core.tool.Model):
else:
return None
custom_offset = props.custom_offset
direction_sense = layer_params["direction_sense"]
if direction_sense == "POSITIVE" and custom_offset_reference in {"INTERIOR", "TOP"}:
layer_offset = custom_offset - thickness * unit_scale
if direction_sense == "POSITIVE" and custom_offset_reference in {"CENTER", "MIDDLE"}:
layer_offset = custom_offset - (thickness / 2) * unit_scale
if (direction_sense == "POSITIVE" and custom_offset_reference in {"EXTERIOR", "BOTTOM"}) or (
direction_sense == "NEGATIVE" and custom_offset_reference in {"EXTERIOR", "TOP"}
):
layer_offset = custom_offset
if direction_sense == "NEGATIVE" and custom_offset_reference in {"CENTER", "MIDDLE"}:
layer_offset = custom_offset + (thickness / 2) * unit_scale
if direction_sense == "NEGATIVE" and custom_offset_reference in {"INTERIOR", "BOTTOM"}:
layer_offset = custom_offset + thickness * unit_scale
direction_sense = layer_params["direction_sense"]
if direction_sense == "POSITIVE" and custom_offset_reference in {"INTERIOR", "TOP"}:
layer_offset = custom_offset - thickness * unit_scale
if direction_sense == "POSITIVE" and custom_offset_reference in {"CENTER", "MIDDLE"}:
layer_offset = custom_offset - (thickness / 2) * unit_scale
if (direction_sense == "POSITIVE" and custom_offset_reference in {"EXTERIOR", "BOTTOM"}) or (
direction_sense == "NEGATIVE" and custom_offset_reference in {"EXTERIOR", "TOP"}
):
layer_offset = custom_offset
if direction_sense == "NEGATIVE" and custom_offset_reference in {"CENTER", "MIDDLE"}:
layer_offset = custom_offset + (thickness / 2) * unit_scale
if direction_sense == "NEGATIVE" and custom_offset_reference in {"INTERIOR", "BOTTOM"}:
layer_offset = custom_offset + thickness * unit_scale
return layer_offset / unit_scale
return None
return layer_offset / unit_scale
@classmethod
def get_booleans(