diff --git a/src/bonsai/bonsai/bim/module/model/prop.py b/src/bonsai/bonsai/bim/module/model/prop.py index 7245dfb443..dece203282 100644 --- a/src/bonsai/bonsai/bim/module/model/prop.py +++ b/src/bonsai/bonsai/bim/module/model/prop.py @@ -373,7 +373,12 @@ class BIMStairProperties(PropertyGroup): if self.stair_type != "WOOD/STEEL" and self.nosing_length < 0: self["nosing_length"] = 0 - non_si_units_props = ("is_editing", "number_of_treads", "has_top_nib", "stair_type") + def update_custom_tread_lock(self, context: bpy.types.Context) -> None: + """When lock is enabled, sync custom treads with tread_run""" + if self.custom_tread_lock: + self["custom_first_last_tread_run"] = (self.tread_run, self.tread_run) + + non_si_units_props = ("is_editing", "number_of_treads", "has_top_nib", "stair_type", "custom_tread_lock") is_editing: bpy.props.BoolProperty(default=False) width: bpy.props.FloatProperty(name="Width", default=1.2, soft_min=0.01, subtype="DISTANCE") @@ -407,6 +412,12 @@ class BIMStairProperties(PropertyGroup): default="CONCRETE", update=validate_nosing_value, ) + custom_tread_lock: bpy.props.BoolProperty( + name="Lock First/Last Treads to Tread Run", + description="When enabled, first and last treads automatically use the Tread Run value", + default=True, + update=update_custom_tread_lock, + ) custom_first_last_tread_run: bpy.props.FloatVectorProperty( name="Custom First / Last Treads Widths", description='Specify custom first / last treads widths, different from the general "Tread Run". Leave 0 to disable.', @@ -442,6 +453,7 @@ class BIMStairProperties(PropertyGroup): top_slab_depth: float has_top_nib: bool stair_type: str + custom_tread_lock: bool custom_first_last_tread_run: tuple[float, float] nosing_length: float nosing_depth: float @@ -480,17 +492,38 @@ class BIMStairProperties(PropertyGroup): } stair_kwargs.update(generic_props) - # defined here to appear last in UI - stair_kwargs["custom_first_last_tread_run"] = self.custom_first_last_tread_run + # If locked, use tread_run for both first and last treads + if self.custom_tread_lock: + stair_kwargs["custom_first_last_tread_run"] = (self.tread_run, self.tread_run) + else: + stair_kwargs["custom_first_last_tread_run"] = self.custom_first_last_tread_run if not convert_to_project_units: return stair_kwargs stair_kwargs = tool.Model.convert_data_to_project_units(stair_kwargs, self.non_si_units_props) return stair_kwargs + + def get_props_kwargs_for_ifc_export(self, convert_to_project_units=False, stair_type=None): + """Get props including custom_tread_lock for saving to IFC""" + stair_kwargs = self.get_props_kwargs(convert_to_project_units, stair_type) + # Add the lock state for IFC storage (after getting base kwargs to avoid passing to generate function) + stair_kwargs["custom_tread_lock"] = self.custom_tread_lock + return stair_kwargs def set_props_kwargs_from_ifc_data(self, kwargs): kwargs = tool.Model.convert_data_to_si_units(kwargs, self.non_si_units_props) + + # Determine lock state based on whether custom treads match tread_run + # If custom_tread_lock wasn't saved (old files), infer it from the data + if "custom_tread_lock" not in kwargs: + custom_treads = kwargs.get("custom_first_last_tread_run", (0.0, 0.0)) + tread_run = kwargs.get("tread_run", 0.3) + # Lock is off if either custom tread differs from tread_run and is not 0 + kwargs["custom_tread_lock"] = not any( + ct != 0.0 and ct != tread_run for ct in custom_treads + ) + for prop_name in kwargs: setattr(self, prop_name, kwargs[prop_name]) diff --git a/src/bonsai/bonsai/bim/module/model/stair.py b/src/bonsai/bonsai/bim/module/model/stair.py index d2b5184c76..71bfa97a9a 100644 --- a/src/bonsai/bonsai/bim/module/model/stair.py +++ b/src/bonsai/bonsai/bim/module/model/stair.py @@ -188,7 +188,8 @@ class AddStair(bpy.types.Operator, tool.Ifc.Operator): props = tool.Model.get_stair_props(obj) ifc_file = tool.Ifc.get() - stair_data = props.get_props_kwargs(convert_to_project_units=True) + # Use the special method that includes custom_tread_lock for IFC storage + stair_data = props.get_props_kwargs_for_ifc_export(convert_to_project_units=True) pset = tool.Pset.get_element_pset(element, "BBIM_Stair") if not pset: pset = ifcopenshell.api.pset.add_pset(ifc_file, product=element, name="BBIM_Stair") @@ -241,7 +242,8 @@ class FinishEditingStair(bpy.types.Operator, tool.Ifc.Operator): assert element props = tool.Model.get_stair_props(obj) - data = props.get_props_kwargs(convert_to_project_units=True) + # Use the special method that includes custom_tread_lock for IFC storage + data = props.get_props_kwargs_for_ifc_export(convert_to_project_units=True) props.is_editing = False regenerate_stair_mesh(obj) tool.Model.add_body_representation(obj) diff --git a/src/bonsai/bonsai/bim/module/model/ui.py b/src/bonsai/bonsai/bim/module/model/ui.py index 0b17f6d9fb..b62672230c 100644 --- a/src/bonsai/bonsai/bim/module/model/ui.py +++ b/src/bonsai/bonsai/bim/module/model/ui.py @@ -302,13 +302,36 @@ class BIM_PT_stair(bpy.types.Panel): row.operator("bim.cancel_editing_stair", icon="CANCEL", text="") row = self.layout.row(align=True) for prop_name in props.get_props_kwargs(): + # Skip custom_tread_lock as it's handled with custom_first_last_tread_run + if prop_name == "custom_tread_lock": + continue + prop_value = getattr(props, prop_name) - if isinstance(prop_value, Iterable) and not isinstance(prop_value, str): + + # Special handling for custom_first_last_tread_run + if prop_name == "custom_first_last_tread_run": + # Draw the lock toggle + row_lock = self.layout.row(align=True) + lock_text = "Lock First/Last Treads" if not props.custom_tread_lock else "Unlock First/Last Treads" + row_lock.prop( + props, + "custom_tread_lock", + text=lock_text, + icon="LOCKED" if props.custom_tread_lock else "UNLOCKED", + ) + + # Only show the custom values input if unlocked + if not props.custom_tread_lock: + prop_readable_name = props.bl_rna.properties[prop_name].name + self.layout.label(text=f"{prop_readable_name}:") + self.layout.prop(props, prop_name, text="") + elif isinstance(prop_value, Iterable) and not isinstance(prop_value, str): prop_readable_name = props.bl_rna.properties[prop_name].name self.layout.label(text=f"{prop_readable_name}:") self.layout.prop(props, prop_name, text="") else: self.layout.prop(props, prop_name) + if prop_name == "height": # Weak but we just want to insert this inside props drawing row_length = self.layout.row(align=True) row_length.prop(props, "total_length_target") diff --git a/src/bonsai/bonsai/tool/model.py b/src/bonsai/bonsai/tool/model.py index 10181c5d93..bdf09ceacd 100644 --- a/src/bonsai/bonsai/tool/model.py +++ b/src/bonsai/bonsai/tool/model.py @@ -1399,7 +1399,6 @@ class Model(bonsai.core.tool.Model): number_of_risers = number_of_treads + 1 tread_rise = height / number_of_risers - custom_tread_run = any(run != 0 for run in custom_first_last_tread_run) nosing_overlap = max(nosing_length, 0) nosing_tread_gap = -min(nosing_length, 0) nosing_overlap_offset = -V_(nosing_overlap, 0) @@ -1430,19 +1429,26 @@ class Model(bonsai.core.tool.Model): default_tread_offset = Vector([tread_run, tread_rise]) def get_tread_data(i): - if custom_tread_run: - current_tread_run = None - if i == 0: - current_tread_run = custom_first_last_tread_run[0] - elif i == number_of_risers - 1: - current_tread_run = custom_first_last_tread_run[1] + # Check if this is first or last tread with custom run + current_tread_run = None + if i == 0 and custom_first_last_tread_run[0] is not None: + current_tread_run = custom_first_last_tread_run[0] + elif i == number_of_risers - 1 and custom_first_last_tread_run[1] is not None: + current_tread_run = custom_first_last_tread_run[1] - if current_tread_run: - tread_offset = default_tread_offset.copy() - tread_offset.x = current_tread_run - tread_verts = deepcopy(default_tread_verts) - tread_verts[-1].x = current_tread_run - return tread_offset, tread_verts + if current_tread_run is not None: + tread_offset = default_tread_offset.copy() + tread_offset.x = current_tread_run + + # Handle zero-width treads + if current_tread_run == 0: + # For zero width, just return vertical offset with no horizontal tread + return tread_offset, () + + tread_verts = deepcopy(default_tread_verts) + tread_verts[-1].x = current_tread_run + return tread_offset, tread_verts + return default_tread_offset, default_tread_verts # treads @@ -1450,9 +1456,13 @@ class Model(bonsai.core.tool.Model): for i in range(number_of_risers): last_vert_i = len(vertices) - 1 tread_offset, tread_verts = get_tread_data(i) - current_tread_verts = [v + current_offset for v in tread_verts] - edges.extend(default_tread_edges + last_vert_i) - vertices.extend(current_tread_verts) + + # Skip adding vertices/edges for zero-width treads + if tread_verts: + current_tread_verts = [v + current_offset for v in tread_verts] + edges.extend(default_tread_edges + last_vert_i) + vertices.extend(current_tread_verts) + current_offset += tread_offset if stair_type == "WOOD/STEEL": @@ -1467,35 +1477,47 @@ class Model(bonsai.core.tool.Model): default_tread_offset = V_(tread_run + nosing_tread_gap, tread_rise) def get_tread_data(i): - if custom_tread_run: - current_tread_run = None - if i == 0 and custom_first_last_tread_run[0] != 0: - current_tread_run = custom_first_last_tread_run[0] - elif i == number_of_risers - 1 and custom_first_last_tread_run[1] != 0: - current_tread_run = custom_first_last_tread_run[1] + # Check if this is first or last tread with custom run + current_tread_run = None + if i == 0 and custom_first_last_tread_run[0] is not None: + current_tread_run = custom_first_last_tread_run[0] + elif i == number_of_risers - 1 and custom_first_last_tread_run[1] is not None: + current_tread_run = custom_first_last_tread_run[1] - if current_tread_run: - tread_offset = default_tread_offset.copy() - tread_offset.x = current_tread_run + nosing_tread_gap - tread_verts = get_tread_verts(size=V_(current_tread_run + nosing_overlap, tread_depth)) - return tread_offset, tread_verts + if current_tread_run is not None: + tread_offset = default_tread_offset.copy() + tread_offset.x = current_tread_run + nosing_tread_gap + + # Handle zero-width treads + if current_tread_run == 0: + return tread_offset, () + + tread_verts = get_tread_verts(size=V_(current_tread_run + nosing_overlap, tread_depth)) + return tread_offset, tread_verts + return default_tread_offset, default_tread_verts # each tread is a separate shape cur_offset = V_(0, 0) + tread_index = 0 for i in range(number_of_risers): tread_offset, tread_verts = get_tread_data(i) - cur_trade_shape = [v + cur_offset + nosing_overlap_offset for v in tread_verts] - vertices.extend(cur_trade_shape) + + # Skip adding vertices/edges for zero-width treads + if tread_verts: + cur_trade_shape = [v + cur_offset + nosing_overlap_offset for v in tread_verts] + vertices.extend(cur_trade_shape) - cur_vertex = i * 4 - verts_to_add = ( - (cur_vertex, cur_vertex + 1), - (cur_vertex + 1, cur_vertex + 2), - (cur_vertex + 2, cur_vertex + 3), - (cur_vertex + 3, cur_vertex), - ) - edges.extend(verts_to_add) + cur_vertex = tread_index * 4 + verts_to_add = ( + (cur_vertex, cur_vertex + 1), + (cur_vertex + 1, cur_vertex + 2), + (cur_vertex + 2, cur_vertex + 3), + (cur_vertex + 3, cur_vertex), + ) + edges.extend(verts_to_add) + tread_index += 1 + cur_offset += tread_offset elif stair_type == "GENERIC": diff --git a/src/bonsai/test/tool/test_model.py b/src/bonsai/test/tool/test_model.py index 35d5c6bc57..04fdd881a6 100644 --- a/src/bonsai/test/tool/test_model.py +++ b/src/bonsai/test/tool/test_model.py @@ -176,6 +176,27 @@ class TestStairCalculatedParams(NewFile): calculated_data["Length"] += -0.2 + 0.1 self.compare_data(pset_data, calculated_data) + # zero-width first tread + pset_data = pset_data_base.copy() + calculated_data = calculated_data_base.copy() + pset_data["custom_first_last_tread_run"] = (0.0, 0.0) + calculated_data["Length"] = 0.9 # Only 3 treads at 0.3 each + self.compare_data(pset_data, calculated_data) + + # zero-width last tread + pset_data = pset_data_base.copy() + calculated_data = calculated_data_base.copy() + pset_data["custom_first_last_tread_run"] = (0.3, 0.0) + calculated_data["Length"] = 0.9 # Only 3 treads at 0.3 each + self.compare_data(pset_data, calculated_data) + + # both first and last treads zero-width + pset_data = pset_data_base.copy() + calculated_data = calculated_data_base.copy() + pset_data["custom_first_last_tread_run"] = (0.0, 0.0) + calculated_data["Length"] = 0.6 # Only 2 middle treads at 0.3 each + self.compare_data(pset_data, calculated_data) + # overlap affects stair length only by first tread pset_data = pset_data_base.copy() calculated_data = calculated_data_base.copy() @@ -296,6 +317,94 @@ class TestGenerateStair2DProfile(NewFile): generated_profile = subject.generate_stair_2d_profile(**kwargs) self.compare_data(generated_profile, expected_profile) + def test_create_concrete_stair_zero_width_first_tread(self): + """Test concrete stair with zero-width first tread""" + kwargs = { + "base_slab_depth": 0.25, + "has_top_nib": False, + "height": 1.0, + "number_of_treads": 3, + "stair_type": "CONCRETE", + "top_slab_depth": 0.25, + "tread_depth": 0.25, + "tread_run": 0.3, + "width": 1.2, + "custom_first_last_tread_run": (0.0, 0.0), + } + verts_data = ( + V(0.0, 0, 0.0), + # First tread skipped - goes straight to second tread + V(0.0, 0, 0.5), + V(0.3, 0, 0.5), + V(0.3, 0, 0.75), + V(0.6, 0, 0.75), + V(0.6, 0, 1.0), + V(0.9, 0, 1.0), + V(0.9, 0, 0.67457), + V(0.0, 0, -0.25), + ) + edges_data = ( + (0, 1), + (1, 2), + (2, 3), + (3, 4), + (4, 5), + (5, 6), + (6, 7), + (8, 0), + (7, 8), + ) + edges_data = [e[::-1] for e in edges_data] + faces_data = () + expected_profile = (verts_data, edges_data, faces_data) + generated_profile = subject.generate_stair_2d_profile(**kwargs) + self.compare_data(generated_profile, expected_profile) + + def test_create_concrete_stair_zero_width_last_tread(self): + """Test concrete stair with zero-width last tread""" + kwargs = { + "base_slab_depth": 0.25, + "has_top_nib": False, + "height": 1.0, + "number_of_treads": 3, + "stair_type": "CONCRETE", + "top_slab_depth": 0.25, + "tread_depth": 0.25, + "tread_run": 0.3, + "width": 1.2, + "custom_first_last_tread_run": (0.0, 0.0), + } + verts_data = ( + V(0.0, 0, 0.0), + V(0.0, 0, 0.25), + V(0.3, 0, 0.25), + V(0.3, 0, 0.5), + V(0.6, 0, 0.5), + V(0.6, 0, 0.75), + V(0.9, 0, 0.75), + # Last tread skipped + V(0.9, 0, 0.67457), + V(0.1, 0, -0.25), + V(0.0, 0, -0.25), + ) + edges_data = ( + (0, 1), + (1, 2), + (2, 3), + (3, 4), + (4, 5), + (5, 6), + (6, 7), + (9, 0), + (8, 9), + (7, 8), + ) + edges_data = [e[::-1] for e in edges_data] + faces_data = () + expected_profile = (verts_data, edges_data, faces_data) + generated_profile = subject.generate_stair_2d_profile(**kwargs) + self.compare_data(generated_profile, expected_profile) + def test_create_wood_steel_stair(self): kwargs = { "height": 1.0, @@ -348,6 +457,100 @@ class TestGenerateStair2DProfile(NewFile): generated_profile = subject.generate_stair_2d_profile(**kwargs) self.compare_data(generated_profile, expected_profile) + def test_create_wood_steel_stair_zero_width_first_tread(self): + """Test wood/steel stair with zero-width first tread""" + kwargs = { + "height": 1.0, + "number_of_treads": 3, + "stair_type": "WOOD/STEEL", + "tread_depth": 0.25, + "tread_run": 0.3, + "width": 1.2, + "custom_first_last_tread_run": (0.0, 0.0), + } + verts_data = ( + # First tread skipped - start at second tread + V(0.0, 0, 0.25), + V(0.3, 0, 0.25), + V(0.3, 0, 0.5), + V(0.0, 0, 0.5), + V(0.3, 0, 0.5), + V(0.6, 0, 0.5), + V(0.6, 0, 0.75), + V(0.3, 0, 0.75), + V(0.6, 0, 0.75), + V(0.9, 0, 0.75), + V(0.9, 0, 1.0), + V(0.6, 0, 1.0), + ) + edges_data = ( + (0, 1), + (1, 2), + (2, 3), + (3, 0), + (4, 5), + (5, 6), + (6, 7), + (7, 4), + (8, 9), + (9, 10), + (10, 11), + (11, 8), + ) + + faces_data = () + + expected_profile = (verts_data, edges_data, faces_data) + generated_profile = subject.generate_stair_2d_profile(**kwargs) + self.compare_data(generated_profile, expected_profile) + + def test_create_wood_steel_stair_zero_width_last_tread(self): + """Test wood/steel stair with zero-width last tread""" + kwargs = { + "height": 1.0, + "number_of_treads": 3, + "stair_type": "WOOD/STEEL", + "tread_depth": 0.25, + "tread_run": 0.3, + "width": 1.2, + "custom_first_last_tread_run": (0.0, 0.0), + } + verts_data = ( + V(0.0, 0, 0.0), + V(0.3, 0, 0.0), + V(0.3, 0, 0.25), + V(0.0, 0, 0.25), + V(0.3, 0, 0.25), + V(0.6, 0, 0.25), + V(0.6, 0, 0.5), + V(0.3, 0, 0.5), + V(0.6, 0, 0.5), + V(0.9, 0, 0.5), + V(0.9, 0, 0.75), + V(0.6, 0, 0.75), + # Last tread skipped + ) + edges_data = ( + (0, 1), + (1, 2), + (2, 3), + (3, 0), + (4, 5), + (5, 6), + (6, 7), + (7, 4), + (8, 9), + (9, 10), + (10, 11), + (11, 8), + ) + + faces_data = () + + expected_profile = (verts_data, edges_data, faces_data) + generated_profile = subject.generate_stair_2d_profile(**kwargs) + self.compare_data(generated_profile, expected_profile) + def test_create_generic_stair(self): kwargs = {"height": 1.0, "number_of_treads": 3, "stair_type": "GENERIC", "tread_run": 0.3, "width": 1.2} verts_data = ( @@ -381,6 +584,41 @@ class TestGenerateStair2DProfile(NewFile): generated_profile = subject.generate_stair_2d_profile(**kwargs) self.compare_data(generated_profile, expected_profile) + def test_create_generic_stair_zero_width_treads(self): + """Test generic stair with zero-width first and last treads""" + kwargs = { + "height": 1.0, + "number_of_treads": 3, + "stair_type": "GENERIC", + "tread_run": 0.3, + "width": 1.2, + "custom_first_last_tread_run": (0.0, 0.0), + } + verts_data = ( + V(0.0, 0, 0.0), + # First tread skipped + V(0.0, 0, 0.5), + V(0.3, 0, 0.5), + V(0.3, 0, 0.75), + V(0.6, 0, 0.75), + # Last tread skipped + V(0.6, 0, 0.0), + ) + edges_data = ( + (0, 1), + (1, 2), + (2, 3), + (3, 4), + (4, 5), + (5, 0), + ) + edges_data = [e[::-1] for e in edges_data] + + faces_data = () + expected_profile = (verts_data, edges_data, faces_data) + generated_profile = subject.generate_stair_2d_profile(**kwargs) + self.compare_data(generated_profile, expected_profile) + class TestUsingArrays(NewFile): def setup_array(self, add_second_layer=False, sync_children=False):