Update to gizmos system

Added more gizmos for multi-panel windows and for the door transom.
Support negative dimension values (lining offset for door and window)
Fix railing, stair, and roof being regenerated during UI panel draw instead of on property change

Various code quality changes and DRY improvements
This commit is contained in:
Gorgious
2025-12-01 23:13:34 +01:00
parent 349cbf27b9
commit c769c9e67c
7 changed files with 1747 additions and 1002 deletions
File diff suppressed because it is too large Load Diff
@@ -161,8 +161,7 @@ classes = (
stair.FinishEditingStair,
stair.EnableEditingStair,
stair.RemoveStair,
stair.ToggleStairTotalLengthLock,
stair.ToggleStairCustomTreadLock,
stair.ToggleStairProperty,
stair.AdjustStairTreads,
stair.SetStairTreads,
stair.CycleStairType,
+159 -218
View File
@@ -29,7 +29,6 @@ import ifcopenshell.util.representation
import ifcopenshell.util.schema
import ifcopenshell.util.unit
import bonsai.tool as tool
import bonsai.core.geometry
import bonsai.core.geometry as core
import bonsai.core.root
from bonsai.bim.module.model.window import create_bm_window, create_bm_box
@@ -41,10 +40,16 @@ from mathutils import Vector, Matrix
import json
import collections
import collections.abc
from typing import get_args
from typing import get_args, TYPE_CHECKING
if TYPE_CHECKING:
from bonsai.bim.module.model.prop import BIMDoorProperties
V_ = tool.Blender.V_
# Shorthand for gizmo offset constants used in DimensionGizmoConfig lambdas
_G = gizmo.BaseParametricGizmoGroup
def update_door_modifier_representation(obj: bpy.types.Object) -> None:
props = tool.Model.get_door_props(obj)
@@ -142,7 +147,7 @@ def update_door_modifier_representation(obj: bpy.types.Object) -> None:
plan_representation = ifcopenshell.api.geometry.add_door_representation(ifc_file, **representation_data)
tool.Model.replace_object_ifc_representation(plan_annotation, obj, plan_representation)
bonsai.core.geometry.switch_representation(
core.switch_representation(
tool.Ifc,
tool.Geometry,
obj=obj,
@@ -512,7 +517,7 @@ class BIM_OT_add_door(bpy.types.Operator, tool.Ifc.Operator):
element = bonsai.core.root.assign_class(
tool.Ifc, tool.Collector, tool.Root, obj=obj, ifc_class="IfcDoor", should_add_representation=False
)
bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=obj)
core.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=obj)
if tool.Ifc.get_schema() != "IFC2X3":
element.PredefinedType = "DOOR"
@@ -556,7 +561,7 @@ class AddDoor(bpy.types.Operator, tool.Ifc.Operator):
)
update_door_modifier_representation(obj)
def _execute(self, context: bpy.types.Context) -> set[str]:
def _execute(self, context: bpy.types.Context) -> set[str]: # noqa: ARG002
for obj in tool.Blender.get_selected_objects():
if not tool.Blender.Modifier.is_eligible_for_door_modifier(obj):
continue
@@ -584,7 +589,7 @@ class CancelEditingDoor(bpy.types.Operator, tool.Ifc.Operator):
props.set_props_kwargs_from_ifc_data(data)
body = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW")
bonsai.core.geometry.switch_representation(
core.switch_representation(
tool.Ifc,
tool.Geometry,
obj=obj,
@@ -593,7 +598,7 @@ class CancelEditingDoor(bpy.types.Operator, tool.Ifc.Operator):
props.is_editing = False
def _execute(self, context):
def _execute(self, context: bpy.types.Context) -> set[str]: # noqa: ARG002
for obj in tool.Blender.get_selected_objects():
self.cancel_editing_door_on_object(obj)
return {"FINISHED"}
@@ -605,7 +610,7 @@ class FinishEditingDoor(bpy.types.Operator, tool.Ifc.Operator):
bl_description = "Apply changes and finish editing door parameters"
bl_options = {"REGISTER", "UNDO"}
def finish_editing_door_on_object(self, obj):
def finish_editing_door_on_object(self, obj: bpy.types.Object) -> None:
element = tool.Ifc.get_entity(obj)
assert element
if not tool.Blender.Modifier.is_door(element):
@@ -630,7 +635,7 @@ class FinishEditingDoor(bpy.types.Operator, tool.Ifc.Operator):
door_data = tool.Ifc.get().createIfcText(json.dumps(door_data, default=list))
ifcopenshell.api.pset.edit_pset(tool.Ifc.get(), pset=pset, properties={"Data": door_data})
def _execute(self, context):
def _execute(self, context: bpy.types.Context) -> set[str]: # noqa: ARG002
for obj in tool.Blender.get_selected_objects():
self.finish_editing_door_on_object(obj)
return {"FINISHED"}
@@ -642,7 +647,7 @@ class EnableEditingDoor(bpy.types.Operator, tool.Ifc.Operator):
bl_description = "Enter edit mode to modify door parameters interactively"
bl_options = {"REGISTER", "UNDO"}
def edit_door_on_obj(self, obj):
def edit_door_on_obj(self, obj: bpy.types.Object) -> None:
element = tool.Ifc.get_entity(obj)
assert element
if not tool.Blender.Modifier.is_door(element):
@@ -657,7 +662,7 @@ class EnableEditingDoor(bpy.types.Operator, tool.Ifc.Operator):
props.set_props_kwargs_from_ifc_data(data)
props.is_editing = True
def _execute(self, context):
def _execute(self, context: bpy.types.Context) -> set[str]: # noqa: ARG002
for obj in tool.Blender.get_selected_objects():
self.edit_door_on_obj(obj)
return {"FINISHED"}
@@ -668,7 +673,7 @@ class RemoveDoor(bpy.types.Operator, tool.Ifc.Operator):
bl_label = "Remove Door on Selected Objects"
bl_options = {"REGISTER", "UNDO"}
def remove_door_on_object(self, obj):
def remove_door_on_object(self, obj: bpy.types.Object) -> None:
element = tool.Ifc.get_entity(obj)
assert element
if not tool.Blender.Modifier.is_door(element):
@@ -679,7 +684,7 @@ class RemoveDoor(bpy.types.Operator, tool.Ifc.Operator):
pset = tool.Pset.get_element_pset(element, "BBIM_Door")
ifcopenshell.api.pset.remove_pset(tool.Ifc.get(), product=element, pset=pset)
def _execute(self, context):
def _execute(self, context: bpy.types.Context) -> set[str]: # noqa: ARG002
for obj in tool.Blender.get_selected_objects():
self.remove_door_on_object(obj)
return {"FINISHED"}
@@ -702,7 +707,7 @@ class ToggleDoorSwing(bpy.types.Operator, tool.Ifc.Operator):
name="Skip Direction Change", default=False, options={"HIDDEN", "SKIP_SAVE"}
)
def invoke(self, context, event):
def invoke(self, context: bpy.types.Context, event: bpy.types.Event) -> set[str]:
self.skip_direction_change = event.shift
return self.execute(context)
@@ -719,7 +724,7 @@ class ToggleDoorSwing(bpy.types.Operator, tool.Ifc.Operator):
return True
return False
def _execute(self, context):
def _execute(self, context: bpy.types.Context) -> set[str]: # noqa: ARG002
obj = tool.Blender.get_active_object()
if not obj:
return {"CANCELLED"}
@@ -742,36 +747,20 @@ class ToggleDoorSwing(bpy.types.Operator, tool.Ifc.Operator):
return {"FINISHED"}
class CycleDoorType(bpy.types.Operator, tool.Ifc.Operator):
class CycleDoorType(bpy.types.Operator, tool.Ifc.Operator, gizmo.CycleTypeMixin):
"""Cycle through available door types. Shift+click to cycle in reverse."""
bl_idname = "bim.cycle_door_type"
bl_label = "Cycle Door Type"
bl_options = {"REGISTER", "UNDO"}
reverse: bpy.props.BoolProperty(name="Reverse", default=False, options={"HIDDEN", "SKIP_SAVE"})
element_checker = "is_door"
props_getter = "get_door_props"
type_literal = tool.Model.DoorType
type_attr = "door_type"
def invoke(self, context, event):
self.reverse = event.shift
return self.execute(context)
def _execute(self, context):
obj = tool.Blender.get_active_object()
if not obj:
return {"CANCELLED"}
element = tool.Ifc.get_entity(obj)
if not element or not tool.Blender.Modifier.is_door(element):
return {"CANCELLED"}
props = tool.Model.get_door_props(obj)
door_types = get_args(tool.Model.DoorType)
current_index = door_types.index(props.door_type) if props.door_type in door_types else 0
direction = -1 if self.reverse else 1
next_index = (current_index + direction) % len(door_types)
props.door_type = door_types[next_index]
return {"FINISHED"}
def _execute(self, context: bpy.types.Context) -> set[str]:
return self._cycle_type(context)
class GizmoDoorEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
@@ -786,56 +775,110 @@ class GizmoDoorEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
cancel_editing_operator = "bim.cancel_editing_door"
cycle_type_operator = "bim.cycle_door_type"
# Declarative dimension gizmo configuration with visibility and position
# matrix_position lambdas replace the get_dimension_matrix_* methods
dimension_gizmo_props = [
DimensionGizmoConfig(attr_name="overall_width", axis=(1, 0, 0), min_value=0.01, text_offset_sign=-1),
DimensionGizmoConfig(attr_name="overall_height", axis=(0, 0, 1), min_value=0.01, text_alignment="start"),
DimensionGizmoConfig(attr_name="threshold_thickness", axis=(0, 0, 1)),
DimensionGizmoConfig(attr_name="threshold_depth", axis=(0, 1, 0)),
DimensionGizmoConfig(attr_name="threshold_offset", axis=(0, 1, 0)),
DimensionGizmoConfig(attr_name="lining_offset", axis=(0, 1, 0)),
DimensionGizmoConfig(attr_name="lining_depth", axis=(0, 1, 0)),
DimensionGizmoConfig(attr_name="lining_thickness", axis=(-1, 0, 0)),
DimensionGizmoConfig(attr_name="transom_offset", axis=(0, 0, 1)),
DimensionGizmoConfig(attr_name="transom_thickness", axis=(0, 0, 1)),
DimensionGizmoConfig(attr_name="casing_thickness", axis=(-1, 0, 0)),
DimensionGizmoConfig(attr_name="casing_depth", axis=(0, 1, 0)),
DimensionGizmoConfig(
attr_name="overall_width", axis=(1, 0, 0), min_value=0.01, text_offset_sign=-1,
# Position set dynamically in _update_dimension_gizmo_positions based on view
),
DimensionGizmoConfig(
attr_name="overall_height", axis=(0, 0, 1), min_value=0.01, text_alignment="start",
# Position set dynamically in _update_dimension_gizmo_positions based on view
),
DimensionGizmoConfig(
attr_name="threshold_thickness", axis=(0, 0, 1),
matrix_position=lambda p: V_(p.overall_width / 2, p.threshold_offset + p.threshold_depth, 0),
),
DimensionGizmoConfig(
attr_name="threshold_depth", axis=(0, 1, 0),
visibility_condition=lambda p: p.has_threshold_depth(),
matrix_position=lambda p: V_(p.overall_width / 2, p.threshold_offset, p.threshold_thickness),
),
DimensionGizmoConfig(
attr_name="threshold_offset", axis=(0, 1, 0),
matrix_position=lambda p: V_(p.overall_width / 2 - _G.GIZMO_STACK_OFFSET, 0, p.threshold_thickness),
),
DimensionGizmoConfig(
attr_name="lining_offset", axis=(0, 1, 0), min_value=-10.0,
# Position set dynamically in _update_dimension_gizmo_positions based on view
),
DimensionGizmoConfig(
attr_name="lining_depth", axis=(0, 1, 0),
matrix_position=lambda p: V_(p.overall_width, p.lining_offset, p.overall_height),
),
DimensionGizmoConfig(
attr_name="lining_thickness", axis=(-1, 0, 0),
matrix_position=lambda p: V_(p.overall_width, p.lining_depth / 2, p.overall_height / 2),
),
DimensionGizmoConfig(
attr_name="transom_offset", axis=(0, 0, 1),
visibility_condition=lambda p: p.has_transom(),
matrix_position=lambda p: V_(p.overall_width / 2, p.lining_offset, 0),
),
DimensionGizmoConfig(
attr_name="transom_thickness", axis=(0, 0, 1),
matrix_position=lambda p: V_(p.overall_width / 2, p.lining_offset, p.transom_offset),
),
DimensionGizmoConfig(
attr_name="casing_thickness", axis=(-1, 0, 0),
visibility_condition=lambda p: p.has_casing(),
matrix_position=lambda p: V_(
p.lining_thickness,
p.lining_depth + p.lining_offset + p.casing_depth / 2,
p.overall_height / 2
),
),
DimensionGizmoConfig(
attr_name="casing_depth", axis=(0, 1, 0),
visibility_condition=lambda p: p.has_casing_depth(),
matrix_position=lambda p: V_(
p.lining_thickness - p.casing_thickness,
p.lining_depth + p.lining_offset,
p.overall_height / 2
),
),
DimensionGizmoConfig(
attr_name="panel_depth", axis=(0, 1, 0),
matrix_position=lambda p: V_(
p.lining_to_panel_offset_x + p.overall_width * p.panel_width_ratio / 2,
p.lining_offset + p.lining_to_panel_offset_y,
p.threshold_thickness + p.get_panel_center_z()
),
),
DimensionGizmoConfig(
attr_name="frame_thickness", axis=(-1, 0, 0),
visibility_condition=lambda p: p.has_transom(),
matrix_position=lambda p: V_(
p.overall_width,
p.lining_offset + p.lining_to_panel_offset_y + p.frame_depth / 2,
p.get_transom_window_center_z()
),
),
DimensionGizmoConfig(
attr_name="frame_depth", axis=(0, 1, 0),
visibility_condition=lambda p: p.has_transom(),
matrix_position=lambda p: V_(
p.overall_width - p.frame_thickness,
p.lining_offset + p.lining_to_panel_offset_y,
p.get_transom_window_center_z()
),
),
]
props_getter = "get_door_props"
gizmo_pref_name = "door"
@classmethod
def is_element_type(cls, element) -> bool:
def is_element_type(cls, element: ifcopenshell.entity_instance) -> bool:
return tool.Blender.Modifier.is_door(element)
def get_props(self, obj: bpy.types.Object):
return tool.Model.get_door_props(obj)
def get_icon_y_extent(self, props: "BIMDoorProperties") -> tuple[float, float]:
"""Get Y extents for door icon positioning.
def get_gizmo_prefs(self):
prefs = tool.Blender.get_addon_preferences()
return prefs.gizmos.door
def should_hide_gizmo(self, attr_name: str, props) -> bool:
"""Door-specific visibility rules for gizmos."""
if not props.is_editing:
return True
if attr_name == "threshold_depth" and props.threshold_thickness == 0.0:
return True
if attr_name == "transom_offset" and props.transom_thickness == 0.0:
return True
if attr_name == "casing_thickness" and props.lining_offset != 0.0:
return True
if attr_name == "casing_depth" and (props.lining_offset != 0.0 or props.casing_thickness == 0.0):
return True
return False
def get_icon_y_offset(self, context: bpy.types.Context, mw: Matrix) -> float:
"""Get Y offset for icons based on view direction.
Positions icons further than the furthest geometry:
max(threshold_offset + threshold_depth, lining_offset + lining_depth) + 2 * GIZMO_OFFSET
Door geometry extends in +Y direction from lining/threshold.
Icons are positioned beyond max(threshold_offset + depth, lining_offset + depth).
"""
obj = context.active_object
if not obj:
return self.ICON_Y_OFFSET
props = self.get_props(obj)
furthest_y = (
max(
props.threshold_offset + props.threshold_depth,
@@ -843,154 +886,53 @@ class GizmoDoorEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
)
+ 2 * self.GIZMO_OFFSET
)
return (furthest_y, furthest_y)
viewing_from_negative_y, _ = self.get_local_view_direction(context, mw)
if viewing_from_negative_y:
return -furthest_y
return furthest_y
def get_dimension_matrix_threshold_thickness(self, props) -> Matrix:
return self.compose_gizmo_matrix(
V_(props.overall_width / 2, props.threshold_offset + props.threshold_depth, 0), (0, 0, 1)
)
def get_dimension_matrix_threshold_depth(self, props) -> Matrix:
return self.compose_gizmo_matrix(
V_(props.overall_width / 2, props.threshold_offset, props.threshold_thickness), (0, 1, 0)
)
def get_dimension_matrix_threshold_offset(self, props) -> Matrix:
return self.compose_gizmo_matrix(
V_(props.overall_width / 2 - 0.1, 0, props.threshold_thickness), (0, 1, 0)
)
def get_dimension_matrix_lining_offset(self, props) -> Matrix:
return self.compose_gizmo_matrix(V_(0, 0, 0), (0, 1, 0))
def get_dimension_matrix_lining_depth(self, props) -> Matrix:
return self.compose_gizmo_matrix(
V_(props.overall_width, props.lining_offset, props.overall_height), (0, 1, 0)
)
def get_dimension_matrix_lining_thickness(self, props) -> Matrix:
return self.compose_gizmo_matrix(
V_(props.overall_width, props.lining_depth / 2, props.overall_height / 2), (-1, 0, 0)
)
def get_dimension_matrix_transom_offset(self, props) -> Matrix:
return self.compose_gizmo_matrix(
V_(props.overall_width / 2, props.lining_offset, 0), (0, 0, 1)
)
def get_dimension_matrix_transom_thickness(self, props) -> Matrix:
return self.compose_gizmo_matrix(
V_(props.overall_width / 2, props.lining_offset, props.transom_offset), (0, 0, 1)
)
@staticmethod
def _get_casing_gizmo_base_position(props) -> tuple[float, float, float]:
"""Get common base position for casing gizmos."""
x = props.lining_thickness
y_base = props.lining_depth + props.lining_offset
z = props.overall_height / 2
return x, y_base, z
def get_dimension_matrix_casing_thickness(self, props) -> Matrix:
x, y_base, z = self._get_casing_gizmo_base_position(props)
return self.compose_gizmo_matrix(V_(x, y_base + props.casing_depth / 2, z), (-1, 0, 0))
def get_dimension_matrix_casing_depth(self, props) -> Matrix:
x, y_base, z = self._get_casing_gizmo_base_position(props)
return self.compose_gizmo_matrix(V_(x - props.casing_thickness, y_base, z), (0, 1, 0))
def get_dimension_matrix_overall_width(self, props) -> Matrix:
"""Position width dimension below the door."""
return self.compose_gizmo_matrix(
V_(0, props.lining_offset - self.GIZMO_OFFSET, -self.GIZMO_OFFSET), (1, 0, 0)
)
def get_dimension_matrix_overall_height(self, props) -> Matrix:
"""Position height dimension to the side of the door."""
casing_offset = props.casing_thickness if props.lining_offset == 0.0 else 0.0
return self.compose_gizmo_matrix(
V_(props.overall_width + casing_offset + self.GIZMO_OFFSET, props.lining_offset - self.GIZMO_OFFSET, 0),
(0, 0, 1),
)
def setup(self, context: bpy.types.Context) -> None:
self.setup_editing_gizmos(context)
self.setup_dimension_gizmos(context)
def setup_element_specific_gizmos(self, context: bpy.types.Context) -> None:
"""Create door-specific swing arc gizmos."""
prefs = tool.Blender.get_addon_preferences()
highlight_color = prefs.decorator_color_selected[:3]
inactive_color = prefs.decorator_color_background[:3]
special_color = prefs.decorator_color_special[:3]
self.gizmo_door_type = self.gizmos.new("VIEW3D_GT_arc")
self.gizmo_door_type.use_draw_scale = False
self.gizmo_door_type.color = special_color
self.gizmo_door_type.alpha = 0.5
self.gizmo_door_type.color_highlight = highlight_color
self.gizmo_door_type.prop_path = "BIMDoorProperties.door_type"
op = self.gizmo_door_type.target_set_operator("bim.toggle_door_swing")
op.flip_geometry = False
self.gizmo_door_type = self.create_arc_gizmo(
special_color, "bim.toggle_door_swing",
prop_path="BIMDoorProperties.door_type",
flip_geometry=False,
)
self.gizmo_flip_arc = self.create_arc_gizmo(
inactive_color, "bim.toggle_door_swing",
prop_path="BIMDoorProperties.door_type",
flip_geometry=True,
flip_local_axes="XY",
)
self.gizmo_flip_arc = self.gizmos.new("VIEW3D_GT_arc")
self.gizmo_flip_arc.use_draw_scale = False
self.gizmo_flip_arc.color = inactive_color
self.gizmo_flip_arc.alpha = 0.5
self.gizmo_flip_arc.color_highlight = highlight_color
self.gizmo_flip_arc.prop_path = "BIMDoorProperties.door_type"
op = self.gizmo_flip_arc.target_set_operator("bim.toggle_door_swing")
op.flip_geometry = True
op.flip_local_axes = "XY"
def refresh(self, context: bpy.types.Context) -> None:
if not self.is_setup_complete():
return
obj = context.active_object
if not obj:
return
props = self.get_props(obj)
mw = obj.matrix_world
def _refresh_element_specific(
self, context: bpy.types.Context, mw: Matrix, props: "BIMDoorProperties" # noqa: ARG002
) -> None:
"""Update door-specific swing arc gizmos."""
self.update_swing_gizmos(mw, props)
self.update_editing_gizmos(context, mw, props)
self.update_dimension_gizmos(mw, props)
def _update_dimension_gizmo_positions(self, context: bpy.types.Context, mw: Matrix, props) -> None:
def get_casing_offset(self, props: "BIMDoorProperties") -> float:
"""Override to return casing_thickness when lining_offset is 0."""
return props.get_casing_offset()
def _update_dimension_gizmo_positions(self, context: bpy.types.Context, mw: Matrix, props: "BIMDoorProperties") -> None:
"""Update dimension gizmo positions based on camera view direction."""
viewing_from_negative_y, viewing_from_negative_x = self.get_local_view_direction(context, mw)
y_pos = self.get_lining_y_position_for_view(props, viewing_from_negative_y)
self._update_view_dependent_dimensions(context, mw, props)
self.set_dimension_gizmo_position("overall_width", mw, V_(0, y_pos, -self.GIZMO_OFFSET), (1, 0, 0))
casing_offset = props.casing_thickness if props.lining_offset == 0.0 else 0.0
if viewing_from_negative_x:
x_pos = -casing_offset - self.GIZMO_OFFSET
else:
x_pos = props.overall_width + casing_offset + self.GIZMO_OFFSET
self.set_dimension_gizmo_position("overall_height", mw, V_(x_pos, y_pos, 0), (0, 0, 1))
def update_swing_gizmos(self, mw: Matrix, props) -> None:
def update_swing_gizmos(self, mw: Matrix, props: "BIMDoorProperties") -> None:
"""Update swing gizmo position and color based on editing state."""
door_type_hidden_by_modal = self.is_gizmo_hidden_by_modal(self.gizmo_door_type)
flip_arc_hidden_by_modal = self.is_gizmo_hidden_by_modal(self.gizmo_flip_arc)
prefs = tool.Blender.get_addon_preferences()
door_gizmo_prefs = prefs.gizmos.door
if door_type_hidden_by_modal:
self.gizmo_door_type.hide = True
else:
prefs = tool.Blender.get_addon_preferences()
door_gizmo_prefs = prefs.gizmos.door
self.gizmo_door_type.hide = not props.is_editing or not door_gizmo_prefs.swing_arc
door_type_visible = self.update_gizmo_visibility(
self.gizmo_door_type, props.is_editing, door_gizmo_prefs.swing_arc
)
flip_arc_visible = self.update_gizmo_visibility(
self.gizmo_flip_arc, props.is_editing, door_gizmo_prefs.flip_arc
)
if flip_arc_hidden_by_modal:
self.gizmo_flip_arc.hide = True
else:
prefs = tool.Blender.get_addon_preferences()
door_gizmo_prefs = prefs.gizmos.door
self.gizmo_flip_arc.hide = not props.is_editing or not door_gizmo_prefs.flip_arc
if self.gizmo_door_type.hide and self.gizmo_flip_arc.hide:
if not door_type_visible and not flip_arc_visible:
return
swing_x_offset = props.overall_width if "RIGHT" in props.door_type else 0.0
@@ -998,11 +940,10 @@ class GizmoDoorEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
props.overall_width, 4
)
prefs = tool.Blender.get_addon_preferences()
if not self.gizmo_door_type.hide:
if door_type_visible:
self.gizmo_door_type.matrix_basis = mw @ base_swing_transform
self.gizmo_door_type.color = prefs.decorations_colour[:3]
if not self.gizmo_flip_arc.hide:
if flip_arc_visible:
mirror_y = Matrix.Scale(-1, 4, (0, 1, 0))
self.gizmo_flip_arc.matrix_basis = mw @ base_swing_transform @ mirror_y
+379 -129
View File
@@ -29,7 +29,8 @@ from bonsai.bim.module.model.decorator import WallAxisDecorator, SlabDirectionDe
from bonsai.bim.module.model.door import update_door_modifier_bmesh
from bonsai.bim.module.model.window import update_window_modifier_bmesh
from bonsai.bim.module.drawing.decoration import CutDecorator
from typing import TYPE_CHECKING, Literal, get_args, Union, get_args, Any, Optional
from typing import TYPE_CHECKING, Literal, get_args, Union, Any, Optional, Callable
from mathutils import Vector
def get_ifc_class(self: "BIMModelProperties", context: bpy.types.Context) -> list[tuple[str, str, str]]:
@@ -152,6 +153,53 @@ def update_window(self: "BIMWindowProperties", context: bpy.types.Context) -> No
update_window_modifier_bmesh(context)
# Lazy-loaded module references for parametric element updates
# Using module-level lazy loading avoids circular imports and repeated import overhead
_updater_cache: dict[str, Callable] = {}
def _get_updater(module_name: str, func_name: str) -> Callable:
"""Lazy-load an updater function to avoid circular imports.
Args:
module_name: Module name within bonsai.bim.module.model (e.g., "stair")
func_name: Function name to import (e.g., "regenerate_stair_mesh")
Returns:
The imported function, cached for subsequent calls.
"""
cache_key = f"{module_name}.{func_name}"
if cache_key not in _updater_cache:
import importlib
module = importlib.import_module(f"bonsai.bim.module.model.{module_name}")
_updater_cache[cache_key] = getattr(module, func_name)
return _updater_cache[cache_key]
def update_stair(self: "BIMStairProperties", context: bpy.types.Context) -> None:
"""Regenerate stair mesh when property changes."""
obj = context.active_object
if obj and self.is_editing:
_get_updater("stair", "regenerate_stair_mesh")(obj)
def update_railing(self: "BIMRailingProperties", context: bpy.types.Context) -> None:
"""Regenerate railing mesh when property changes."""
if self.is_editing:
# Only FRAMELESS_PANEL can update live via bmesh.
# WALL_MOUNTED_HANDRAIL geometry is generated from IFC representation,
# so it only updates on "Finish Editing" to avoid modifying IFC during preview.
if self.railing_type == "FRAMELESS_PANEL":
_get_updater("railing", "update_railing_modifier_bmesh")(context)
def update_roof(self: "BIMRoofProperties", context: bpy.types.Context) -> None:
"""Regenerate roof mesh when property changes."""
obj = context.active_object
if obj and self.is_editing:
_get_updater("roof", "update_roof_modifier_bmesh")(obj)
class BIMModelProperties(PropertyGroup):
ifc_class: bpy.props.EnumProperty(items=get_ifc_class, name="Construction Class", update=update_ifc_class)
relating_type_id: bpy.props.EnumProperty(
@@ -353,153 +401,65 @@ class BIMArrayProperties(PropertyGroup):
def update_total_length_target(self: "BIMStairProperties", context: bpy.types.Context) -> None:
"""Update tread_run when total_length_target changes"""
# Calculate available length for default treads
available_length = self.total_length_target
n_default_treads = self.number_of_treads + 1 # number of risers
# Subtract custom first tread if not locked and not zero
if not self.custom_tread_lock and self.custom_first_last_tread_run[0] != 0:
available_length -= self.custom_first_last_tread_run[0]
n_default_treads -= 1
# Subtract custom last tread if not locked and not zero
if not self.custom_tread_lock and self.custom_first_last_tread_run[1] != 0:
available_length -= self.custom_first_last_tread_run[1]
n_default_treads -= 1
# Calculate tread_run for remaining treads
if n_default_treads > 0:
self["tread_run"] = available_length / n_default_treads
else:
# All treads are custom, just use target length
self["tread_run"] = self.total_length_target / (self.number_of_treads + 1)
"""Update tread_run when total_length_target changes."""
self.update_tread_run_from_length()
# Must call update_stair here because self["prop"] bypasses property update callbacks
update_stair(self, context)
def update_tread_run(self: "BIMStairProperties", context: bpy.types.Context) -> None:
"""Update either number_of_treads or total_length_target when tread_run changes"""
"""Update either number_of_treads or total_length_target when tread_run changes."""
if self.total_length_lock:
# Calculate how much length custom treads take up
custom_length = 0
n_custom_treads = 0
if not self.custom_tread_lock:
if self.custom_first_last_tread_run[0] != 0:
custom_length += self.custom_first_last_tread_run[0]
n_custom_treads += 1
if self.custom_first_last_tread_run[1] != 0:
custom_length += self.custom_first_last_tread_run[1]
n_custom_treads += 1
# Calculate how many default treads fit in remaining space
custom_length, custom_count = self.get_custom_tread_info()
available_length = self.total_length_target - custom_length
if self.tread_run > 0:
n_default_treads = available_length / self.tread_run
total_treads = n_default_treads + n_custom_treads
# number_of_treads = number_of_risers - 1
self["number_of_treads"] = int(total_treads - 1)
self["number_of_treads"] = int(n_default_treads + custom_count - 1)
else:
# Calculate total length from current settings
n_default_treads = self.number_of_treads + 1
total_length = 0
if not self.custom_tread_lock:
if self.custom_first_last_tread_run[0] != 0:
total_length += self.custom_first_last_tread_run[0]
n_default_treads -= 1
if self.custom_first_last_tread_run[1] != 0:
total_length += self.custom_first_last_tread_run[1]
n_default_treads -= 1
total_length += n_default_treads * self.tread_run
self["total_length_target"] = total_length
self.update_total_length_from_treads()
# Must call update_stair here because self["prop"] bypasses property update callbacks
update_stair(self, context)
def update_number_of_treads(self: "BIMStairProperties", context: bpy.types.Context) -> None:
"""Update either tread_run or total_length_target when number_of_treads changes"""
"""Update either tread_run or total_length_target when number_of_treads changes."""
if self.total_length_lock:
# Calculate available length for default treads
available_length = self.total_length_target
n_default_treads = self.number_of_treads + 1
if not self.custom_tread_lock:
if self.custom_first_last_tread_run[0] != 0:
available_length -= self.custom_first_last_tread_run[0]
n_default_treads -= 1
if self.custom_first_last_tread_run[1] != 0:
available_length -= self.custom_first_last_tread_run[1]
n_default_treads -= 1
if n_default_treads > 0:
self["tread_run"] = available_length / n_default_treads
else:
self["tread_run"] = self.total_length_target / (self.number_of_treads + 1)
self.update_tread_run_from_length()
else:
# Calculate total length from current settings
n_default_treads = self.number_of_treads + 1
total_length = 0
if not self.custom_tread_lock:
if self.custom_first_last_tread_run[0] != 0:
total_length += self.custom_first_last_tread_run[0]
n_default_treads -= 1
if self.custom_first_last_tread_run[1] != 0:
total_length += self.custom_first_last_tread_run[1]
n_default_treads -= 1
total_length += n_default_treads * self.tread_run
self["total_length_target"] = total_length
self.update_total_length_from_treads()
# Must call update_stair here because self["prop"] bypasses property update callbacks
update_stair(self, context)
def update_custom_first_last_tread_run(self: "BIMStairProperties", context: bpy.types.Context) -> None:
"""Update tread_run or total_length when custom treads change"""
"""Update tread_run or total_length when custom treads change."""
if self.total_length_lock:
# Recalculate tread_run to maintain total length
available_length = self.total_length_target
n_default_treads = self.number_of_treads + 1
if not self.custom_tread_lock:
if self.custom_first_last_tread_run[0] != 0:
available_length -= self.custom_first_last_tread_run[0]
n_default_treads -= 1
if self.custom_first_last_tread_run[1] != 0:
available_length -= self.custom_first_last_tread_run[1]
n_default_treads -= 1
if n_default_treads > 0:
self["tread_run"] = available_length / n_default_treads
self.update_tread_run_from_length()
else:
# Recalculate total length
n_default_treads = self.number_of_treads + 1
total_length = 0
if not self.custom_tread_lock:
if self.custom_first_last_tread_run[0] != 0:
total_length += self.custom_first_last_tread_run[0]
n_default_treads -= 1
if self.custom_first_last_tread_run[1] != 0:
total_length += self.custom_first_last_tread_run[1]
n_default_treads -= 1
total_length += n_default_treads * self.tread_run
self["total_length_target"] = total_length
self.update_total_length_from_treads()
# Must call update_stair here because self["prop"] bypasses property update callbacks
update_stair(self, context)
class BIMStairProperties(PropertyGroup):
def validate_nosing_value(self, context: bpy.types.Context) -> None:
if self.stair_type != "WOOD/STEEL" and self.nosing_length < 0:
self["nosing_length"] = 0
update_stair(self, context)
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)
update_stair(self, context)
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, min=0.01, subtype="DISTANCE")
height: bpy.props.FloatProperty(name="Height", default=1.0, min=0.01, subtype="DISTANCE")
width: bpy.props.FloatProperty(name="Width", default=1.2, min=0.01, subtype="DISTANCE", update=update_stair)
height: bpy.props.FloatProperty(name="Height", default=1.0, min=0.01, subtype="DISTANCE", update=update_stair)
number_of_treads: bpy.props.IntProperty(
name="Number of Treads", default=6, soft_min=1, min=0, update=update_number_of_treads
)
@@ -516,13 +476,13 @@ class BIMStairProperties(PropertyGroup):
name="Lock Total Length",
description="Lock Total Length when changing number of treads or tread run",
)
tread_depth: bpy.props.FloatProperty(name="Tread Depth", default=0.25, min=0.01, subtype="DISTANCE")
tread_depth: bpy.props.FloatProperty(name="Tread Depth", default=0.25, min=0.01, subtype="DISTANCE", update=update_stair)
tread_run: bpy.props.FloatProperty(
name="Tread Run", default=0.3, min=0.01, subtype="DISTANCE", update=update_tread_run
)
base_slab_depth: bpy.props.FloatProperty(name="Base Slab Depth", default=0.25, min=0, subtype="DISTANCE")
top_slab_depth: bpy.props.FloatProperty(name="Top Slab Depth", default=0.25, min=0, subtype="DISTANCE")
has_top_nib: bpy.props.BoolProperty(name="Has Top Nib", default=True)
base_slab_depth: bpy.props.FloatProperty(name="Base Slab Depth", default=0.25, min=0, subtype="DISTANCE", update=update_stair)
top_slab_depth: bpy.props.FloatProperty(name="Top Slab Depth", default=0.25, min=0, subtype="DISTANCE", update=update_stair)
has_top_nib: bpy.props.BoolProperty(name="Has Top Nib", default=True, update=update_stair)
stair_type: bpy.props.EnumProperty(
name="Stair Type",
items=[(i, i.replace("/", " / ").title(), "") for i in get_args(tool.Model.StairType)],
@@ -555,7 +515,7 @@ class BIMStairProperties(PropertyGroup):
update=validate_nosing_value,
)
nosing_depth: bpy.props.FloatProperty(
name="Nosing Depth", description="Depth of the tread's nosing", min=0, default=0, unit="LENGTH"
name="Nosing Depth", description="Depth of the tread's nosing", min=0, default=0, unit="LENGTH", update=update_stair
)
if TYPE_CHECKING:
@@ -659,6 +619,100 @@ class BIMStairProperties(PropertyGroup):
continue
setattr(target_props, prop_name, prop_value)
def is_concrete_stair(self) -> bool:
return self.stair_type == "CONCRETE"
def has_nosing(self) -> bool:
return self.nosing_length != 0.0 and self.stair_type != "WOOD/STEEL"
def has_custom_treads(self) -> bool:
return not self.custom_tread_lock
def has_tread_run_gizmo(self) -> bool:
return self.custom_tread_lock or self.number_of_treads > 2
def has_tread_depth(self) -> bool:
return self.stair_type != "GENERIC"
def get_riser_height(self) -> float:
"""Compute the riser height from total height and number of treads."""
return self.height / (self.number_of_treads + 1)
def set_riser_height(self, value: float) -> None:
"""Apply riser height by adjusting the total stair height."""
self.height = max(0.01, value) * (self.number_of_treads + 1)
def get_total_run(self) -> float:
"""Calculate the total horizontal run of the stair.
Takes into account custom first/last tread runs when custom_tread_lock is False.
"""
number_of_rises = self.number_of_treads + 1
total_run = 0.0
default_rises = number_of_rises
if not self.custom_tread_lock:
if self.custom_first_last_tread_run[0] is not None: # May be 0 though
default_rises -= 1
total_run += self.custom_first_last_tread_run[0]
if self.custom_first_last_tread_run[1] is not None: # May be 0 though
default_rises -= 1
total_run += self.custom_first_last_tread_run[1]
total_run += self.tread_run * default_rises
return total_run
def get_custom_tread_run(self, index: int) -> float:
"""Get custom tread run value for first (0) or last (1) tread."""
return self.custom_first_last_tread_run[index]
def set_custom_tread_run(self, index: int, value: float) -> None:
"""Set custom tread run value for first (0) or last (1) tread."""
current = self.custom_first_last_tread_run
if index == 0:
self.custom_first_last_tread_run = (max(0.01, value), current[1])
else:
self.custom_first_last_tread_run = (current[0], max(0.01, value))
def get_custom_tread_info(self) -> tuple[float, int]:
"""Calculate total custom tread length and count.
Returns:
Tuple of (custom_length, custom_count)
"""
custom_length = 0.0
custom_count = 0
if not self.custom_tread_lock:
if self.custom_first_last_tread_run[0] != 0:
custom_length += self.custom_first_last_tread_run[0]
custom_count += 1
if self.custom_first_last_tread_run[1] != 0:
custom_length += self.custom_first_last_tread_run[1]
custom_count += 1
return custom_length, custom_count
def calculate_total_length(self) -> float:
"""Calculate total stair run length from current properties."""
custom_length, custom_count = self.get_custom_tread_info()
n_default_treads = self.number_of_treads + 1 - custom_count
return custom_length + n_default_treads * self.tread_run
def update_tread_run_from_length(self) -> None:
"""Recalculate tread_run to maintain total_length_target."""
custom_length, custom_count = self.get_custom_tread_info()
available_length = self.total_length_target - custom_length
n_default_treads = self.number_of_treads + 1 - custom_count
if n_default_treads > 0:
self["tread_run"] = available_length / n_default_treads
else:
# All treads are custom, use fallback
self["tread_run"] = self.total_length_target / (self.number_of_treads + 1)
def update_total_length_from_treads(self) -> None:
"""Recalculate total_length_target from current tread settings."""
self["total_length_target"] = self.calculate_total_length()
class BIMSverchokProperties(PropertyGroup):
node_group: bpy.props.PointerProperty(name="Node Group", type=NodeTree)
@@ -875,6 +929,167 @@ class BIMWindowProperties(PropertyGroup):
if prop_name not in exclude_props:
setattr(target_props, prop_name, prop_value)
# Window type feature mapping - centralized configuration for all window type checks
# Each window type maps to its features: mullion, second_mullion, transom, second_transom, panels
WINDOW_TYPE_FEATURES: dict[str, dict[str, bool | int]] = {
"SINGLE_PANEL": {"panels": 1},
"DOUBLE_PANEL_VERTICAL": {"mullion": True, "panels": 2},
"DOUBLE_PANEL_HORIZONTAL": {"transom": True, "panels": 2},
"TRIPLE_PANEL_BOTTOM": {"mullion": True, "transom": True, "panels": 3},
"TRIPLE_PANEL_TOP": {"mullion": True, "transom": True, "panels": 3},
"TRIPLE_PANEL_LEFT": {"mullion": True, "transom": True, "panels": 3},
"TRIPLE_PANEL_RIGHT": {"mullion": True, "transom": True, "panels": 3},
"TRIPLE_PANEL_HORIZONTAL": {"transom": True, "second_transom": True, "panels": 3},
"TRIPLE_PANEL_VERTICAL": {"mullion": True, "second_mullion": True, "panels": 3},
}
def _get_feature(self, feature: str, default: bool | int = False) -> bool | int:
return self.WINDOW_TYPE_FEATURES.get(self.window_type, {}).get(feature, default)
def has_mullion(self) -> bool:
return bool(self._get_feature("mullion"))
def has_second_mullion(self) -> bool:
return bool(self._get_feature("second_mullion"))
def has_transom(self) -> bool:
return bool(self._get_feature("transom"))
def has_second_transom(self) -> bool:
return bool(self._get_feature("second_transom"))
def has_second_panel(self) -> bool:
return int(self._get_feature("panels", 1)) >= 2
def has_third_panel(self) -> bool:
return int(self._get_feature("panels", 1)) >= 3
def get_lining_to_panel_offset_y_full(self) -> float:
"""Get the full Y offset for lining-to-panel positioning."""
return (self.lining_depth - self.frame_depth[0]) + self.lining_to_panel_offset_y
def get_panel_geometry(self, panel_index: int) -> tuple[float, float, float, float]:
"""Get panel geometry (x_offset, z_offset, height, center_z) for a given panel index.
Args:
panel_index: 0 for first panel, 1 for second, 2 for third
Returns:
Tuple of (x_offset, z_offset, height, center_z)
"""
window_type = self.window_type
if panel_index == 0:
# First panel position and height
if window_type == "DOUBLE_PANEL_HORIZONTAL":
x, z = 0, self.first_transom_offset
elif window_type == "TRIPLE_PANEL_HORIZONTAL":
x, z = 0, self.second_transom_offset
elif window_type in ("TRIPLE_PANEL_BOTTOM", "TRIPLE_PANEL_RIGHT", "TRIPLE_PANEL_TOP"):
x, z = 0, self.first_transom_offset
else:
x, z = 0, 0
if window_type == "TRIPLE_PANEL_HORIZONTAL":
height = self.overall_height - self.second_transom_offset
elif window_type in (
"DOUBLE_PANEL_HORIZONTAL",
"TRIPLE_PANEL_BOTTOM",
"TRIPLE_PANEL_RIGHT",
"TRIPLE_PANEL_TOP",
):
height = self.overall_height - self.first_transom_offset
else:
height = self.overall_height
elif panel_index == 1:
# Second panel position and height
if window_type == "DOUBLE_PANEL_VERTICAL":
x, z = self.first_mullion_offset, 0
elif window_type == "DOUBLE_PANEL_HORIZONTAL":
x, z = 0, 0
elif window_type in ("TRIPLE_PANEL_BOTTOM", "TRIPLE_PANEL_LEFT", "TRIPLE_PANEL_RIGHT"):
x, z = self.first_mullion_offset, self.first_transom_offset
elif window_type == "TRIPLE_PANEL_TOP":
x, z = 0, 0
elif window_type == "TRIPLE_PANEL_HORIZONTAL":
x, z = 0, self.first_transom_offset
elif window_type == "TRIPLE_PANEL_VERTICAL":
x, z = self.first_mullion_offset, 0
else:
x, z = 0, 0
if window_type == "DOUBLE_PANEL_HORIZONTAL":
height = self.first_transom_offset
elif window_type == "DOUBLE_PANEL_VERTICAL":
height = self.overall_height
elif window_type in ("TRIPLE_PANEL_BOTTOM", "TRIPLE_PANEL_LEFT", "TRIPLE_PANEL_RIGHT"):
height = self.overall_height - self.first_transom_offset
elif window_type == "TRIPLE_PANEL_TOP":
height = self.first_transom_offset
elif window_type == "TRIPLE_PANEL_HORIZONTAL":
height = self.second_transom_offset - self.first_transom_offset
elif window_type == "TRIPLE_PANEL_VERTICAL":
height = self.overall_height
else:
height = self.overall_height
else: # panel_index == 2
# Third panel position and height
if window_type in ("TRIPLE_PANEL_BOTTOM", "TRIPLE_PANEL_RIGHT", "TRIPLE_PANEL_HORIZONTAL"):
x, z = 0, 0
elif window_type in ("TRIPLE_PANEL_TOP", "TRIPLE_PANEL_LEFT"):
x, z = self.first_mullion_offset, 0
elif window_type == "TRIPLE_PANEL_VERTICAL":
x, z = self.second_mullion_offset, 0
else:
x, z = 0, 0
height = self.overall_height if window_type == "TRIPLE_PANEL_VERTICAL" else self.first_transom_offset
center_z = z + height / 2
return x, z, height, center_z
def get_frame_position(self, panel_index: int, is_depth: bool) -> Vector:
"""Get frame gizmo position for a given panel.
Args:
panel_index: 0 for first panel, 1 for second, 2 for third
is_depth: True for depth gizmo, False for thickness gizmo
Returns:
Position vector for the gizmo
"""
x_offset, _, _, center_z = self.get_panel_geometry(panel_index)
frame_depth = self.frame_depth[panel_index]
y_full = (self.lining_depth - frame_depth) + self.lining_to_panel_offset_y
y_pos = y_full + frame_depth + self.lining_offset
return Vector((x_offset + self.lining_to_panel_offset_x, y_pos, center_z))
def get_frame_value(self, attr_name: str, panel_index: int) -> float:
"""Get frame property value (frame_depth or frame_thickness) for a specific panel.
Args:
attr_name: Property name ("frame_depth" or "frame_thickness")
panel_index: Panel index (0, 1, or 2)
Returns:
The value at the specified panel index
"""
return getattr(self, attr_name)[panel_index]
def set_frame_value(self, attr_name: str, panel_index: int, value: float) -> None:
"""Set frame property value (frame_depth or frame_thickness) for a specific panel.
Args:
attr_name: Property name ("frame_depth" or "frame_thickness")
panel_index: Panel index (0, 1, or 2)
value: New value (clamped to min 0.0)
"""
current = getattr(self, attr_name)
new_value = tuple(current[:panel_index]) + (max(0.0, value),) + tuple(current[panel_index + 1:])
setattr(self, attr_name, new_value)
class BIMDoorProperties(PropertyGroup):
non_si_units_props = (
@@ -1138,6 +1353,36 @@ class BIMDoorProperties(PropertyGroup):
if prop_name not in exclude_props:
setattr(target_props, prop_name, prop_value)
def has_threshold_depth(self) -> bool:
"""Check if threshold depth gizmo should be visible (has threshold)."""
return self.threshold_thickness > 0.0
def has_transom(self) -> bool:
"""Check if transom-related gizmos should be visible (has transom)."""
return self.transom_thickness > 0.0
def has_casing(self) -> bool:
"""Check if casing gizmos should be visible (no lining offset)."""
return self.lining_offset == 0.0
def has_casing_depth(self) -> bool:
"""Check if casing depth gizmo should be visible (has casing and casing thickness > 0)."""
return self.lining_offset == 0.0 and self.casing_thickness > 0.0
def get_panel_center_z(self) -> float:
"""Get the vertical center of the door panel for gizmo positioning."""
if self.transom_thickness > 0:
return (self.transom_offset - self.threshold_thickness) / 2
return (self.overall_height - self.threshold_thickness) / 2
def get_transom_window_center_z(self) -> float:
"""Get the vertical center of the transom window for frame gizmo positioning."""
return (self.transom_offset + self.transom_thickness / 2 + self.overall_height - self.lining_thickness) / 2
def get_casing_offset(self) -> float:
"""Get casing offset for gizmo positioning (casing_thickness when lining_offset is 0)."""
return self.casing_thickness if self.lining_offset == 0.0 else 0.0
RailingType = Literal["FRAMELESS_PANEL", "WALL_MOUNTED_HANDRAIL"]
CapType = Literal["TO_END_POST_AND_FLOOR", "TO_END_POST", "TO_FLOOR", "TO_WALL", "180", "NONE"]
@@ -1156,11 +1401,11 @@ class BIMRailingProperties(PropertyGroup):
is_editing_path: bpy.props.BoolProperty(default=False)
railing_type: bpy.props.EnumProperty(
name="Railing Type", items=[(i, i, "") for i in get_args(RailingType)], default="FRAMELESS_PANEL"
name="Railing Type", items=[(i, i, "") for i in get_args(RailingType)], default="FRAMELESS_PANEL", update=update_railing
)
height: bpy.props.FloatProperty(name="Height", default=1.0, subtype="DISTANCE")
thickness: bpy.props.FloatProperty(name="Thickness", default=0.050, subtype="DISTANCE")
spacing: bpy.props.FloatProperty(name="Spacing", default=0.050, subtype="DISTANCE")
height: bpy.props.FloatProperty(name="Height", default=1.0, subtype="DISTANCE", update=update_railing)
thickness: bpy.props.FloatProperty(name="Thickness", default=0.050, subtype="DISTANCE", update=update_railing)
spacing: bpy.props.FloatProperty(name="Spacing", default=0.050, subtype="DISTANCE", update=update_railing)
# wall mounted handrail specific properties
use_manual_supports: bpy.props.BoolProperty(
@@ -1168,6 +1413,7 @@ class BIMRailingProperties(PropertyGroup):
default=False,
description="If enabled, supports are added on every vertex on the edges of the railing path.\n"
"If disabled, supports are added automatically based on the support spacing",
update=update_railing,
)
support_spacing: bpy.props.FloatProperty(
name="Support Spacing",
@@ -1175,16 +1421,18 @@ class BIMRailingProperties(PropertyGroup):
min=0.01,
description="Distance between supports if automatic supports are used",
subtype="DISTANCE",
update=update_railing,
)
railing_diameter: bpy.props.FloatProperty(name="Railing Diameter", default=0.050, subtype="DISTANCE")
railing_diameter: bpy.props.FloatProperty(name="Railing Diameter", default=0.050, subtype="DISTANCE", update=update_railing)
clear_width: bpy.props.FloatProperty(
name="Clear Width",
default=0.040,
description="Clear width between the railing and the wall",
subtype="DISTANCE",
update=update_railing,
)
terminal_type: bpy.props.EnumProperty(
name="Terminal Type", items=[(i, i, "") for i in get_args(CapType)], default="180"
name="Terminal Type", items=[(i, i, "") for i in get_args(CapType)], default="180", update=update_railing
)
if TYPE_CHECKING:
@@ -1258,9 +1506,11 @@ RoofGenerationMethod = Literal["HEIGHT", "ANGLE"]
class BIMRoofProperties(PropertyGroup):
def update_angle(self, context: bpy.types.Context) -> None:
self["angle"] = to_angle(self.percentage)
update_roof(self, context)
def update_percentage(self, context: bpy.types.Context) -> None:
self["percentage"] = to_percentage(self.angle)
update_roof(self, context)
non_si_units_props = (
"is_editing",
@@ -1276,13 +1526,13 @@ class BIMRoofProperties(PropertyGroup):
is_editing_path: bpy.props.BoolProperty(default=False)
roof_type: bpy.props.EnumProperty(
name="Roof Type", items=[(i, i, "") for i in get_args(RoofType)], default="HIP/GABLE ROOF"
name="Roof Type", items=[(i, i, "") for i in get_args(RoofType)], default="HIP/GABLE ROOF", update=update_roof
)
generation_method: bpy.props.EnumProperty(
name="Roof Generation Method", items=[(i, i, "") for i in get_args(RoofGenerationMethod)], default="ANGLE"
name="Roof Generation Method", items=[(i, i, "") for i in get_args(RoofGenerationMethod)], default="ANGLE", update=update_roof
)
height: bpy.props.FloatProperty(
name="Height", default=1.0, description="Maximum height of the roof to be generated.", subtype="DISTANCE"
name="Height", default=1.0, description="Maximum height of the roof to be generated.", subtype="DISTANCE", update=update_roof
)
angle: bpy.props.FloatProperty(
name="Slope Angle",
@@ -1304,9 +1554,9 @@ class BIMRoofProperties(PropertyGroup):
soft_min=to_percentage(radians(5.0)),
soft_max=to_percentage(radians(60.0)),
)
roof_thickness: bpy.props.FloatProperty(name="Roof Thickness", default=0.1, subtype="DISTANCE")
roof_thickness: bpy.props.FloatProperty(name="Roof Thickness", default=0.1, subtype="DISTANCE", update=update_roof)
rafter_edge_angle: bpy.props.FloatProperty(
name="Rafter Edge Angle", min=0, max=pi / 2, default=pi / 2, subtype="ANGLE"
name="Rafter Edge Angle", min=0, max=pi / 2, default=pi / 2, subtype="ANGLE", update=update_roof
)
if TYPE_CHECKING:
+184 -327
View File
@@ -19,7 +19,6 @@
import bpy
import json
import bmesh
import math
import ifcopenshell
import ifcopenshell.api.pset
import ifcopenshell.util.element
@@ -34,10 +33,11 @@ from mathutils import Vector, Matrix
V_ = tool.Blender.V_
from bmesh.types import BMVert
from bpy.types import Operator
from bpy.props import FloatProperty, IntProperty
from bpy_extras.object_utils import AddObjectHelper, object_data_add
from typing import get_args
from bpy.props import IntProperty
from typing import get_args, TYPE_CHECKING
if TYPE_CHECKING:
from bonsai.bim.module.model.prop import BIMStairProperties
def regenerate_stair_mesh(obj: bpy.types.Object) -> None:
@@ -142,10 +142,10 @@ class BIM_OT_add_stair(bpy.types.Operator, tool.Ifc.Operator):
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context):
def poll(cls, context: bpy.types.Context) -> bool:
return tool.Ifc.get() and context.mode == "OBJECT"
def _execute(self, context):
def _execute(self, context: bpy.types.Context) -> set[str]:
ifc_file = tool.Ifc.get()
if not ifc_file:
self.report({"ERROR"}, "You need to start IFC project first to create a stair.")
@@ -187,7 +187,7 @@ class AddStair(bpy.types.Operator, tool.Ifc.Operator):
bl_description = "Add Bonsai parametric stair to the active IFC element"
bl_options = {"REGISTER", "UNDO"}
def _execute(self, context):
def _execute(self, context: bpy.types.Context) -> set[str]:
obj = context.active_object
assert obj
element = tool.Ifc.get_entity(obj)
@@ -216,6 +216,7 @@ class AddStair(bpy.types.Operator, tool.Ifc.Operator):
regenerate_stair_mesh(obj)
update_ifc_stair_props(obj)
tool.Model.add_body_representation(obj)
return {"FINISHED"}
class CancelEditingStair(bpy.types.Operator, tool.Ifc.Operator):
@@ -224,7 +225,7 @@ class CancelEditingStair(bpy.types.Operator, tool.Ifc.Operator):
bl_description = "Cancel editing and revert stair parameters to their previous values"
bl_options = {"REGISTER"}
def _execute(self, context):
def _execute(self, context: bpy.types.Context) -> set[str]:
obj = context.active_object
assert obj
element = tool.Ifc.get_entity(obj)
@@ -246,7 +247,7 @@ class FinishEditingStair(bpy.types.Operator, tool.Ifc.Operator):
bl_description = "Apply changes and finish editing stair parameters"
bl_options = {"REGISTER"}
def _execute(self, context):
def _execute(self, context: bpy.types.Context) -> set[str]:
obj = context.active_object
assert obj
element = tool.Ifc.get_entity(obj)
@@ -274,7 +275,7 @@ class EnableEditingStair(bpy.types.Operator, tool.Ifc.Operator):
bl_description = "Enter edit mode to modify stair parameters interactively"
bl_options = {"REGISTER"}
def _execute(self, context):
def _execute(self, context: bpy.types.Context) -> set[str]:
obj = context.active_object
assert obj
props = tool.Model.get_stair_props(obj)
@@ -291,7 +292,7 @@ class RemoveStair(bpy.types.Operator, tool.Ifc.Operator):
bl_label = "Remove Stair"
bl_options = {"REGISTER"}
def _execute(self, context):
def _execute(self, context: bpy.types.Context) -> set[str]:
obj = context.active_object
assert obj
props = tool.Model.get_stair_props(obj)
@@ -305,40 +306,40 @@ class RemoveStair(bpy.types.Operator, tool.Ifc.Operator):
return {"FINISHED"}
class ToggleStairTotalLengthLock(bpy.types.Operator):
"""Toggle the total length lock for stair editing"""
class ToggleStairProperty(bpy.types.Operator):
"""Toggle a boolean property on stair properties"""
bl_idname = "bim.toggle_stair_total_length_lock"
bl_label = "Toggle Stair Total Length Lock"
bl_idname = "bim.toggle_stair_property"
bl_label = "Toggle Stair Property"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
property_name: bpy.props.StringProperty(
name="Property Name",
description="Name of the boolean property to toggle",
options={"HIDDEN", "SKIP_SAVE"},
)
# Map property names to their descriptions
PROPERTY_DESCRIPTIONS: dict[str, str] = {
"total_length_lock": "Lock/unlock total stair length. When locked, changing treads adjusts tread depth",
"custom_tread_lock": "Lock/unlock first and last tread dimensions. When unlocked, they can differ from other treads",
}
@classmethod
def description(cls, context: bpy.types.Context, properties: bpy.types.OperatorProperties) -> str:
prop_name = properties.property_name
return cls.PROPERTY_DESCRIPTIONS.get(prop_name, "Toggle a boolean property on stair properties")
def execute(self, context: bpy.types.Context) -> set[str]:
obj = context.active_object
if not obj:
if not obj or not self.property_name:
return {"CANCELLED"}
props = tool.Model.get_stair_props(obj)
props.total_length_lock = not props.total_length_lock
return {"FINISHED"}
class ToggleStairCustomTreadLock(bpy.types.Operator):
"""Toggle custom first/last tread runs. When unlocked, first and last treads can have different lengths."""
bl_idname = "bim.toggle_stair_custom_tread_lock"
bl_label = "Toggle Custom Tread Lock"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
obj = context.active_object
if not obj:
return {"CANCELLED"}
props = tool.Model.get_stair_props(obj)
props.custom_tread_lock = not props.custom_tread_lock
return {"FINISHED"}
if hasattr(props, self.property_name):
setattr(props, self.property_name, not getattr(props, self.property_name))
return {"FINISHED"}
return {"CANCELLED"}
class AdjustStairTreads(bpy.types.Operator):
@@ -350,13 +351,13 @@ class AdjustStairTreads(bpy.types.Operator):
increment: IntProperty(name="Increment", default=1)
def invoke(self, context, event):
def invoke(self, context: bpy.types.Context, event: bpy.types.Event) -> set[str]:
if event.shift:
bpy.ops.bim.set_stair_treads("INVOKE_DEFAULT")
return {"FINISHED"}
return self.execute(context)
def execute(self, context):
def execute(self, context: bpy.types.Context) -> set[str]:
obj = context.active_object
if not obj:
return {"CANCELLED"}
@@ -376,7 +377,7 @@ class SetStairTreads(bpy.types.Operator):
bl_label = "Set Number of Treads"
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
def invoke(self, context, event): # noqa: ARG002
def invoke(self, context: bpy.types.Context, event: bpy.types.Event) -> set[str]: # noqa: ARG002
obj = context.active_object
if not obj:
return {"CANCELLED"}
@@ -390,10 +391,10 @@ class SetStairTreads(bpy.types.Operator):
update_header(context, self._format_header())
return {"RUNNING_MODAL"}
def modal(self, context, event):
def modal(self, context: bpy.types.Context, event: bpy.types.Event) -> set[str]:
return run_integer_input_modal(self, context, event)
def _apply_value(self, context) -> None:
def _apply_value(self, context: bpy.types.Context) -> None:
obj = context.active_object
if not obj:
return
@@ -402,7 +403,7 @@ class SetStairTreads(bpy.types.Operator):
props = tool.Model.get_stair_props(obj)
props.number_of_treads = value
def _restore_value(self, context) -> None:
def _restore_value(self, context: bpy.types.Context) -> None:
obj = context.active_object
if obj:
props = tool.Model.get_stair_props(obj)
@@ -414,51 +415,36 @@ class SetStairTreads(bpy.types.Operator):
return f"Number of Treads: {input_str}_{validity} | Enter to confirm, Esc to cancel"
class CycleStairType(bpy.types.Operator):
class CycleStairType(bpy.types.Operator, gizmo.CycleTypeMixin):
"""Cycle through stair types. Shift+click to cycle in reverse."""
bl_idname = "bim.cycle_stair_type"
bl_label = "Cycle Stair Type"
bl_options = {"REGISTER", "UNDO"}
reverse: bpy.props.BoolProperty(name="Reverse", default=False, options={"HIDDEN", "SKIP_SAVE"})
props_getter = "get_stair_props"
type_literal = tool.Model.StairType
type_attr = "stair_type"
skip_element_check = True
def invoke(self, context, event):
self.reverse = event.shift
return self.execute(context)
def execute(self, context):
obj = context.active_object
if not obj:
return {"CANCELLED"}
props = tool.Model.get_stair_props(obj)
stair_types = get_args(tool.Model.StairType)
current_idx = stair_types.index(props.stair_type) if props.stair_type in stair_types else 0
direction = -1 if self.reverse else 1
props.stair_type = stair_types[(current_idx + direction) % len(stair_types)]
return {"FINISHED"}
def execute(self, context: bpy.types.Context) -> set[str]:
return self._cycle_type(context)
def _compute_first_tread_run(props) -> float:
"""Get the first custom tread run value from the tuple property."""
return props.custom_first_last_tread_run[0]
# Tread run accessors - callbacks that delegate to BIMStairProperties methods
_tread_run_accessors = {
0: (
lambda props: props.get_custom_tread_run(0),
lambda props, value: props.set_custom_tread_run(0, value),
),
1: (
lambda props: props.get_custom_tread_run(1),
lambda props, value: props.set_custom_tread_run(1, value),
),
}
def _apply_first_tread_run(props, value: float) -> None:
"""Apply a new first custom tread run value, preserving the second value."""
props.custom_first_last_tread_run = (max(0.01, value), props.custom_first_last_tread_run[1])
def _compute_last_tread_run(props) -> float:
"""Get the last custom tread run value from the tuple property."""
return props.custom_first_last_tread_run[1]
def _apply_last_tread_run(props, value: float) -> None:
"""Apply a new last custom tread run value, preserving the first value."""
props.custom_first_last_tread_run = (props.custom_first_last_tread_run[0], max(0.01, value))
# Shorthand for gizmo offset constants used in DimensionGizmoConfig lambdas
_G = gizmo.BaseParametricGizmoGroup
class GizmoStairEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
@@ -475,28 +461,22 @@ class GizmoStairEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
ICON_PLUS_X = 1.61 # X position for add tread (+) icon
ICON_MINUS_X = 1.98 # X position for remove tread (-) icon
ICON_PLUS_MINUS_SCALE = 0.24 # Scale for plus/minus icons (slightly larger)
ICON_CYCLE_SCALE = 0.3 # Scale for cycle type icon
ICON_Z_OFFSET = 0.5 # Z offset above geometry for editing icons
enable_editing_operator = "bim.enable_editing_stair"
finish_editing_operator = "bim.finish_editing_stair"
cancel_editing_operator = "bim.cancel_editing_stair"
cycle_type_operator = "bim.cycle_stair_type"
def get_icon_y_offset(self, context, mw):
"""Get Y offset for icons based on view direction.
def get_icon_y_extent(self, props: "BIMStairProperties") -> tuple[float, float]:
"""Get Y extents for stair icon positioning.
Positions icons further than the furthest geometry:
stair_width + 2 * GIZMO_OFFSET
Stair geometry extends from Y=0 to Y=width.
Icons are positioned beyond the width on either side.
"""
obj = context.active_object
if not obj:
return self.ICON_Y_OFFSET
props = self.get_props(obj)
furthest_y = props.width + 2 * self.GIZMO_OFFSET
viewing_from_negative_y, _ = self.get_local_view_direction(context, mw)
if viewing_from_negative_y:
return -furthest_y
return furthest_y
return (furthest_y, furthest_y)
dimension_gizmo_props = [
DimensionGizmoConfig(
@@ -505,267 +485,164 @@ class GizmoStairEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
prop_name="Total Length",
min_value=0.01,
text_offset_sign=-1,
matrix_position=lambda p: V_(0, -_G.GIZMO_OFFSET, -_G.GIZMO_OFFSET),
),
DimensionGizmoConfig(
attr_name="height", axis=(0, 0, 1), min_value=0.01, text_alignment="start",
matrix_position=lambda p: V_(p.get_total_run() + _G.GIZMO_OFFSET, -_G.GIZMO_OFFSET, 0),
),
DimensionGizmoConfig(
attr_name="width", axis=(0, 1, 0), min_value=0.01,
matrix_position=lambda p: V_(_G.GIZMO_OFFSET, 0, -_G.GIZMO_OFFSET),
),
DimensionGizmoConfig(attr_name="height", axis=(0, 0, 1), min_value=0.01, text_alignment="start"),
DimensionGizmoConfig(attr_name="width", axis=(0, 1, 0), min_value=0.01),
DimensionGizmoConfig(
attr_name="tread_run",
axis=(1, 0, 0),
min_value=0.01,
visibility_condition=lambda props: props.custom_tread_lock or props.number_of_treads > 2,
visibility_condition=lambda p: p.has_tread_run_gizmo(),
matrix_position=lambda p: V_(
0 if p.custom_tread_lock else p.custom_first_last_tread_run[0],
0,
p.get_riser_height() if p.custom_tread_lock else p.get_riser_height() * 2
),
),
DimensionGizmoConfig(
attr_name="custom_first_tread_run",
axis=(1, 0, 0),
prop_name="First Tread",
min_value=0.01,
visibility_condition=lambda props: not props.custom_tread_lock,
compute_value=_compute_first_tread_run,
apply_value=_apply_first_tread_run,
visibility_condition=lambda p: p.has_custom_treads(),
compute_value=_tread_run_accessors[0][0],
apply_value=_tread_run_accessors[0][1],
matrix_position=lambda p: V_(0, 0, p.get_riser_height()),
),
DimensionGizmoConfig(
attr_name="custom_last_tread_run",
axis=(1, 0, 0),
prop_name="Last Tread",
min_value=0.01,
visibility_condition=lambda props: not props.custom_tread_lock,
compute_value=_compute_last_tread_run,
apply_value=_apply_last_tread_run,
visibility_condition=lambda p: p.has_custom_treads(),
compute_value=_tread_run_accessors[1][0],
apply_value=_tread_run_accessors[1][1],
matrix_position=lambda p: V_(
p.get_total_run() - p.custom_first_last_tread_run[1], 0, p.height
),
),
DimensionGizmoConfig(
attr_name="nosing_length", axis=(-1, 0, 0),
matrix_position=lambda p: V_(0, p.width / 2, p.get_riser_height()),
),
DimensionGizmoConfig(attr_name="nosing_length", axis=(-1, 0, 0)),
DimensionGizmoConfig(
attr_name="tread_depth",
axis=(0, 0, -1),
visibility_condition=lambda props: props.stair_type != "GENERIC",
visibility_condition=lambda p: p.has_tread_depth(),
matrix_position=lambda p: V_(0, 0, p.get_riser_height()),
),
DimensionGizmoConfig(
attr_name="riser_height",
axis=(0, 0, 1),
min_value=0.01,
text_alignment="start",
compute_value=lambda props: props.height / (props.number_of_treads + 1),
apply_value=lambda props, value: setattr(props, "height", max(0.01, value) * (props.number_of_treads + 1)),
compute_value=lambda p: p.get_riser_height(),
apply_value=lambda p, v: p.set_riser_height(v),
matrix_position=lambda p: V_(p.tread_run, p.width, 0),
),
DimensionGizmoConfig(
attr_name="nosing_depth",
axis=(0, 0, -1),
visibility_condition=lambda props: props.nosing_length != 0.0 and props.stair_type != "WOOD/STEEL",
visibility_condition=lambda p: p.has_nosing(),
matrix_position=lambda p: V_(-p.nosing_length, p.width / 2, p.get_riser_height()),
),
DimensionGizmoConfig(
attr_name="base_slab_depth",
axis=(0, 0, -1),
visibility_condition=lambda props: props.stair_type == "CONCRETE",
visibility_condition=lambda p: p.is_concrete_stair(),
matrix_position=lambda p: V_(0, p.width / 2, 0),
),
DimensionGizmoConfig(
attr_name="top_slab_depth",
axis=(0, 0, -1),
visibility_condition=lambda props: props.stair_type == "CONCRETE",
visibility_condition=lambda p: p.is_concrete_stair(),
matrix_position=lambda p: V_(p.get_total_run(), p.width / 2, p.height),
),
]
# Metadata-driven dispatch for props and preferences
props_getter = "get_stair_props"
gizmo_pref_name = "stair"
@classmethod
def is_element_type(cls, element) -> bool:
def is_element_type(cls, element: ifcopenshell.entity_instance) -> bool:
return tool.Blender.Modifier.is_stair(element)
def get_props(self, obj: bpy.types.Object):
return tool.Model.get_stair_props(obj)
def setup_element_specific_gizmos(self, context: bpy.types.Context) -> None:
"""Create stair-specific icon gizmos (lock, plus, minus)."""
self.lock_gizmo = self.create_icon_gizmo(
"VIEW3D_GT_lock", self.COLOR_BLUE, "bim.toggle_stair_property",
prop_path="BIMStairProperties.total_length_lock",
property_name="total_length_lock",
)
self.tread_lock_gizmo = self.create_icon_gizmo(
"VIEW3D_GT_lock", (1.0, 1.0, 1.0), "bim.toggle_stair_property",
prop_path="BIMStairProperties.custom_tread_lock",
property_name="custom_tread_lock",
)
self.plus_gizmo = self.create_icon_gizmo(
"VIEW3D_GT_plus", self.COLOR_GREEN, "bim.adjust_stair_treads", increment=1
)
self.minus_gizmo = self.create_icon_gizmo(
"VIEW3D_GT_minus", self.COLOR_RED, "bim.adjust_stair_treads", increment=-1
)
def get_gizmo_prefs(self):
prefs = tool.Blender.get_addon_preferences()
return prefs.gizmos.stair
@staticmethod
def _get_stair_total_run(props) -> float:
"""Calculate the total horizontal run of the stair.
Takes into account custom first/last tread runs when custom_tread_lock is False.
"""
number_of_rises = props.number_of_treads + 1
total_run = 0.0
default_rises = number_of_rises
if not props.custom_tread_lock:
if props.custom_first_last_tread_run[0] is not None: # May be 0 though
default_rises -= 1
total_run += props.custom_first_last_tread_run[0]
if props.custom_first_last_tread_run[1] is not None: # May be 0 though
default_rises -= 1
total_run += props.custom_first_last_tread_run[1]
total_run += props.tread_run * default_rises
return total_run
@staticmethod
def _get_first_riser_height(props) -> float:
"""Calculate the height of the first riser."""
return props.height / (props.number_of_treads + 1)
def get_dimension_matrix_total_length_target(self, props) -> Matrix:
return self.compose_gizmo_matrix(V_(0, -self.GIZMO_OFFSET, -self.GIZMO_OFFSET), (1, 0, 0))
def get_dimension_matrix_height(self, props) -> Matrix:
total_run = self._get_stair_total_run(props)
return self.compose_gizmo_matrix(V_(total_run + self.GIZMO_OFFSET, -self.GIZMO_OFFSET, 0), (0, 0, 1))
def get_dimension_matrix_width(self, props) -> Matrix:
return self.compose_gizmo_matrix(V_(self.GIZMO_OFFSET, 0, -self.GIZMO_OFFSET), (0, 1, 0))
def get_dimension_matrix_tread_run(self, props) -> Matrix:
"""Position depends on custom_tread_lock state."""
riser_height = self._get_first_riser_height(props)
if props.custom_tread_lock:
x_offset = 0
z_offset = riser_height
else:
x_offset = props.custom_first_last_tread_run[0]
z_offset = riser_height * 2
return self.compose_gizmo_matrix(V_(x_offset, 0, z_offset), (1, 0, 0))
def get_dimension_matrix_custom_first_tread_run(self, props) -> Matrix:
riser_height = self._get_first_riser_height(props)
return self.compose_gizmo_matrix(V_(0, 0, riser_height), (1, 0, 0))
def get_dimension_matrix_custom_last_tread_run(self, props) -> Matrix:
total_run = self._get_stair_total_run(props)
x_offset = total_run - props.custom_first_last_tread_run[1]
return self.compose_gizmo_matrix(V_(x_offset, 0, props.height), (1, 0, 0))
def get_dimension_matrix_nosing_length(self, props) -> Matrix:
riser_height = self._get_first_riser_height(props)
return self.compose_gizmo_matrix(V_(0, props.width / 2, riser_height), (-1, 0, 0))
def get_dimension_matrix_tread_depth(self, props) -> Matrix:
riser_height = self._get_first_riser_height(props)
return self.compose_gizmo_matrix(V_(0, 0, riser_height), (0, 0, -1))
def get_dimension_matrix_riser_height(self, props) -> Matrix:
return self.compose_gizmo_matrix(V_(props.tread_run, props.width, 0), (0, 0, 1))
def get_dimension_matrix_nosing_depth(self, props) -> Matrix:
riser_height = self._get_first_riser_height(props)
return self.compose_gizmo_matrix(V_(-props.nosing_length, props.width / 2, riser_height), (0, 0, -1))
def get_dimension_matrix_base_slab_depth(self, props) -> Matrix:
return self.compose_gizmo_matrix(V_(0, props.width / 2, 0), (0, 0, -1))
def get_dimension_matrix_top_slab_depth(self, props) -> Matrix:
total_run = self._get_stair_total_run(props)
return self.compose_gizmo_matrix(V_(total_run, props.width / 2, props.height), (0, 0, -1))
def setup(self, context: bpy.types.Context) -> None:
self.setup_editing_gizmos(context)
self.setup_dimension_gizmos(context)
prefs = tool.Blender.get_addon_preferences()
highlight_color = prefs.decorator_color_selected[:3]
self.lock_gizmo = self.gizmos.new("VIEW3D_GT_lock")
self.lock_gizmo.use_draw_scale = False
self.lock_gizmo.color = self.COLOR_BLUE
self.lock_gizmo.color_highlight = highlight_color
self.lock_gizmo.alpha = 0.8
self.lock_gizmo.prop_path = "BIMStairProperties.total_length_lock"
self.lock_gizmo.target_set_operator("bim.toggle_stair_total_length_lock")
self.tread_lock_gizmo = self.gizmos.new("VIEW3D_GT_lock")
self.tread_lock_gizmo.use_draw_scale = False
self.tread_lock_gizmo.color = (1.0, 1.0, 1.0)
self.tread_lock_gizmo.color_highlight = highlight_color
self.tread_lock_gizmo.alpha = 0.8
self.tread_lock_gizmo.prop_path = "BIMStairProperties.custom_tread_lock"
self.tread_lock_gizmo.target_set_operator("bim.toggle_stair_custom_tread_lock")
self.plus_gizmo = self.gizmos.new("VIEW3D_GT_plus")
self.plus_gizmo.use_draw_scale = False
self.plus_gizmo.color = self.COLOR_GREEN
self.plus_gizmo.color_highlight = highlight_color
self.plus_gizmo.alpha = 0.8
op = self.plus_gizmo.target_set_operator("bim.adjust_stair_treads")
op.increment = 1
self.minus_gizmo = self.gizmos.new("VIEW3D_GT_minus")
self.minus_gizmo.use_draw_scale = False
self.minus_gizmo.color = self.COLOR_RED
self.minus_gizmo.color_highlight = highlight_color
self.minus_gizmo.alpha = 0.8
op = self.minus_gizmo.target_set_operator("bim.adjust_stair_treads")
op.increment = -1
def refresh(self, context: bpy.types.Context) -> None:
if not self.is_setup_complete():
return
obj = context.active_object
if not obj:
return
props = self.get_props(obj)
mw = obj.matrix_world
def _refresh_element_specific(
self, context: bpy.types.Context, mw: Matrix, props: "BIMStairProperties"
) -> None:
"""Update stair-specific lock and tread count gizmos."""
billboard_rot = gizmo.get_billboard_rotation(context)
self.update_editing_gizmos(context, mw, props)
self.update_lock_gizmo(mw, props, billboard_rot)
self.update_tread_lock_gizmo(props)
self.update_tread_count_gizmos(props)
self.update_dimension_gizmos(mw, props)
def update_lock_gizmo(self, mw: Matrix, props, billboard_rot: Matrix) -> None:
if self.is_gizmo_hidden_by_modal(self.lock_gizmo):
self.lock_gizmo.hide = True
return
def update_lock_gizmo(self, mw: Matrix, props: "BIMStairProperties", billboard_rot: Matrix) -> None:
"""Update lock gizmo visibility, color, and position."""
gizmo_prefs = self.get_gizmo_prefs()
self.lock_gizmo.hide = not props.is_editing or not gizmo_prefs.lock
if self.lock_gizmo.hide:
return
if not self.update_gizmo_visibility(self.lock_gizmo, props.is_editing, gizmo_prefs.lock):
return # Hidden, skip positioning
self.lock_gizmo.color = self.COLOR_RED if props.total_length_lock else self.COLOR_GREEN
total_run = self._get_stair_total_run(props)
total_run = props.get_total_run()
local_transform = (
Matrix.Translation(Vector((total_run + 0.5, -self.GIZMO_OFFSET, -self.GIZMO_OFFSET)))
Matrix.Translation(Vector((total_run + self.ICON_Z_OFFSET, -self.GIZMO_OFFSET, -self.GIZMO_OFFSET)))
@ billboard_rot
@ Matrix.Scale(self.EDITING_ICON_SCALE, 4)
)
self.lock_gizmo.matrix_basis = mw @ local_transform
def update_tread_lock_gizmo(self, props) -> None:
def update_tread_lock_gizmo(self, props: "BIMStairProperties") -> None:
"""Update visibility of tread lock gizmo. Positioning is handled in _update_editing_icon_positions."""
if not hasattr(self, "tread_lock_gizmo"):
return
if self.is_gizmo_hidden_by_modal(self.tread_lock_gizmo):
self.tread_lock_gizmo.hide = True
return
gizmo_prefs = self.get_gizmo_prefs()
self.tread_lock_gizmo.hide = not props.is_editing or not gizmo_prefs.lock
self.update_gizmo_visibility(self.tread_lock_gizmo, props.is_editing, gizmo_prefs.lock)
def update_tread_count_gizmos(self, props) -> None:
def update_tread_count_gizmos(self, props: "BIMStairProperties") -> None:
"""Update visibility of +/- tread count gizmos. Positioning is handled in _update_editing_icon_positions."""
if not hasattr(self, "plus_gizmo") or not hasattr(self, "minus_gizmo"):
return
gizmo_prefs = self.get_gizmo_prefs()
self.update_gizmo_visibility(self.plus_gizmo, props.is_editing, gizmo_prefs.plus)
# Minus has additional condition: number_of_treads > 1
self.update_gizmo_visibility(
self.minus_gizmo, props.is_editing and props.number_of_treads > 1, gizmo_prefs.minus
)
plus_hidden_by_modal = self.is_gizmo_hidden_by_modal(self.plus_gizmo)
minus_hidden_by_modal = self.is_gizmo_hidden_by_modal(self.minus_gizmo)
if plus_hidden_by_modal:
self.plus_gizmo.hide = True
else:
gizmo_prefs = self.get_gizmo_prefs()
self.plus_gizmo.hide = not props.is_editing or not gizmo_prefs.plus
if minus_hidden_by_modal:
self.minus_gizmo.hide = True
else:
gizmo_prefs = self.get_gizmo_prefs()
self.minus_gizmo.hide = not props.is_editing or props.number_of_treads <= 1 or not gizmo_prefs.minus
def _update_dimension_gizmo_positions(self, context: bpy.types.Context, mw: Matrix, props) -> None:
def _update_dimension_gizmo_positions(self, context: bpy.types.Context, mw: Matrix, props: "BIMStairProperties") -> None:
"""Update dimension gizmo positions based on camera view direction."""
viewing_from_negative_y, viewing_from_negative_x = self.get_local_view_direction(context, mw)
billboard_rot = gizmo.get_billboard_rotation(context)
total_run = self._get_stair_total_run(props)
riser_height = self._get_first_riser_height(props)
total_run = props.get_total_run()
riser_height = props.get_riser_height()
self._update_overall_dimension_gizmos(mw, props, viewing_from_negative_y, viewing_from_negative_x, total_run)
self._update_tread_dimension_gizmos(mw, props, viewing_from_negative_y, total_run, riser_height)
@@ -774,57 +651,37 @@ class GizmoStairEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
self._update_editing_icon_positions(mw, props, viewing_from_negative_y, billboard_rot)
def _update_overall_dimension_gizmos(
self, mw: Matrix, props, viewing_from_negative_y: bool, viewing_from_negative_x: bool, total_run: float
self, mw: Matrix, props: "BIMStairProperties", viewing_from_negative_y: bool, viewing_from_negative_x: bool, total_run: float
) -> None:
"""Update overall dimension gizmos (total_length, width, height)."""
if gizmo := self.get_dimension_gizmo_if_visible("total_length_target"):
y_pos = self.get_y_position_for_view(props, viewing_from_negative_y, use_offset=True)
gizmo.matrix_basis = mw @ self.compose_gizmo_matrix(
V_(0, y_pos, -self.GIZMO_OFFSET), (1, 0, 0)
)
y_pos_offset = self.get_y_position_for_view(props, viewing_from_negative_y, use_offset=True)
x_pos = total_run + self.GIZMO_OFFSET if viewing_from_negative_x else -self.GIZMO_OFFSET
if gizmo := self.get_dimension_gizmo_if_visible("width"):
x_pos = total_run + self.GIZMO_OFFSET if viewing_from_negative_x else -self.GIZMO_OFFSET
gizmo.matrix_basis = mw @ self.compose_gizmo_matrix(
V_(x_pos, 0, -self.GIZMO_OFFSET), (0, 1, 0)
)
if gizmo := self.get_dimension_gizmo_if_visible("height"):
y_pos = self.get_y_position_for_view(props, viewing_from_negative_y, use_offset=True)
gizmo.matrix_basis = mw @ self.compose_gizmo_matrix(
V_(total_run + self.GIZMO_OFFSET, y_pos, 0), (0, 0, 1)
)
self.set_dimension_gizmo_position("total_length_target", mw, V_(0, y_pos_offset, -self.GIZMO_OFFSET), (1, 0, 0))
self.set_dimension_gizmo_position("width", mw, V_(x_pos, 0, -self.GIZMO_OFFSET), (0, 1, 0))
self.set_dimension_gizmo_position("height", mw, V_(total_run + self.GIZMO_OFFSET, y_pos_offset, 0), (0, 0, 1))
def _update_tread_dimension_gizmos(
self, mw: Matrix, props, viewing_from_negative_y: bool, total_run: float, riser_height: float
self, mw: Matrix, props: "BIMStairProperties", viewing_from_negative_y: bool, total_run: float, riser_height: float
) -> None:
"""Update tread-related dimension gizmos (tread_run, custom first/last tread)."""
if gizmo := self.get_dimension_gizmo_if_visible("tread_run"):
y_pos = self.get_y_position_for_view(props, viewing_from_negative_y, use_offset=False)
if props.custom_tread_lock:
x_offset, z_offset = 0, riser_height
else:
x_offset = props.custom_first_last_tread_run[0]
z_offset = riser_height * 2
gizmo.matrix_basis = mw @ self.compose_gizmo_matrix(
V_(x_offset, y_pos, z_offset), (1, 0, 0)
)
y_pos = self.get_y_position_for_view(props, viewing_from_negative_y, use_offset=False)
if gizmo := self.get_dimension_gizmo_if_visible("custom_first_tread_run"):
y_pos = self.get_y_position_for_view(props, viewing_from_negative_y, use_offset=False)
gizmo.matrix_basis = mw @ self.compose_gizmo_matrix(
V_(0, y_pos, riser_height), (1, 0, 0)
)
# tread_run position depends on custom_tread_lock state
if props.custom_tread_lock:
tread_x, tread_z = 0, riser_height
else:
tread_x = props.custom_first_last_tread_run[0]
tread_z = riser_height * 2
self.set_dimension_gizmo_position("tread_run", mw, V_(tread_x, y_pos, tread_z), (1, 0, 0))
if gizmo := self.get_dimension_gizmo_if_visible("custom_last_tread_run"):
y_pos = self.get_y_position_for_view(props, viewing_from_negative_y, use_offset=False)
x_offset = total_run - props.custom_first_last_tread_run[1]
gizmo.matrix_basis = mw @ self.compose_gizmo_matrix(
V_(x_offset, y_pos, props.height), (1, 0, 0)
)
self.set_dimension_gizmo_position("custom_first_tread_run", mw, V_(0, y_pos, riser_height), (1, 0, 0))
last_x = total_run - props.custom_first_last_tread_run[1]
self.set_dimension_gizmo_position("custom_last_tread_run", mw, V_(last_x, y_pos, props.height), (1, 0, 0))
def _update_detail_dimension_gizmos(
self, mw: Matrix, props, viewing_from_negative_y: bool, riser_height: float
self, mw: Matrix, props: "BIMStairProperties", viewing_from_negative_y: bool, riser_height: float
) -> None:
"""Update detail dimension gizmos (nosing, tread depth, riser height)."""
y_pos = self.get_y_position_for_view(props, viewing_from_negative_y, use_offset=False)
@@ -835,25 +692,25 @@ class GizmoStairEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
self.set_dimension_gizmo_position("nosing_depth", mw, V_(-props.nosing_length, props.width / 2, riser_height), (0, 0, -1))
def _update_lock_gizmo_position(
self, mw: Matrix, props, viewing_from_negative_y: bool, billboard_rot: Matrix, total_run: float
self, mw: Matrix, props: "BIMStairProperties", viewing_from_negative_y: bool, billboard_rot: Matrix, total_run: float
) -> None:
"""Update lock gizmo position based on Y view direction."""
y_pos = self.get_y_position_for_view(props, viewing_from_negative_y, use_offset=True)
self.set_icon_gizmo_position(
"lock_gizmo", mw, total_run + 0.5, y_pos, -self.GIZMO_OFFSET, billboard_rot, scale=self.EDITING_ICON_SCALE
"lock_gizmo", mw, total_run + self.ICON_Z_OFFSET, y_pos, -self.GIZMO_OFFSET, billboard_rot, scale=self.EDITING_ICON_SCALE
)
def _update_editing_icon_positions(self, mw, props, viewing_from_negative_y, billboard_rot):
def _update_editing_icon_positions(self, mw: Matrix, props: "BIMStairProperties", viewing_from_negative_y: bool, billboard_rot: Matrix) -> None:
"""Update editing icon positions, flipping Y based on viewing angle."""
if not props.is_editing:
return
icon_z = props.height + 0.5
y_pos = -self.GIZMO_OFFSET if viewing_from_negative_y else props.width + self.GIZMO_OFFSET
icon_z = props.height + self.ICON_Z_OFFSET
y_pos = self.get_icon_y_for_view(props, viewing_from_negative_y)
self.set_icon_gizmo_position("validate_gizmo", mw, 0, y_pos, icon_z, billboard_rot)
self.set_icon_gizmo_position("cancel_gizmo", mw, self.ICON_CANCEL_X, y_pos, icon_z, billboard_rot)
self.set_icon_gizmo_position("cycle_gizmo", mw, self.ICON_CYCLE_X, y_pos, icon_z, billboard_rot, scale=0.3)
self.set_icon_gizmo_position("cycle_gizmo", mw, self.ICON_CYCLE_X, y_pos, icon_z, billboard_rot, scale=self.ICON_CYCLE_SCALE)
self.set_icon_gizmo_position(
"tread_lock_gizmo", mw, self.ICON_TREAD_LOCK_X, y_pos,
icon_z - self.EDITING_ICON_SCALE / 2, billboard_rot, scale=self.EDITING_ICON_SCALE
-10
View File
@@ -31,9 +31,6 @@ from bonsai.bim.module.model.data import (
RailingData,
RoofData,
)
from bonsai.bim.module.model.stair import regenerate_stair_mesh
from bonsai.bim.module.model.railing import update_railing_modifier_bmesh
from bonsai.bim.module.model.roof import update_roof_modifier_bmesh
from collections.abc import Iterable
from typing import Any, TYPE_CHECKING
@@ -307,8 +304,6 @@ class BIM_PT_stair(bpy.types.Panel):
row = self.layout.row(align=True)
draw_stair_properties(self.layout, props)
regenerate_stair_mesh(obj)
else:
calculated_params = StairData.data["calculated_params"]
row.operator("bim.enable_editing_stair", icon="GREASEPENCIL", text="")
@@ -565,9 +560,6 @@ class BIM_PT_railing(bpy.types.Panel):
row.operator("bim.cancel_editing_railing", icon="CANCEL", text="")
draw_railing_properties(self.layout, props)
update_railing_modifier_bmesh(context)
elif props.is_editing_path:
row.operator("bim.finish_editing_railing_path", icon="CHECKMARK", text="")
row.operator("bim.cancel_editing_railing_path", icon="CANCEL", text="")
@@ -623,8 +615,6 @@ class BIM_PT_roof(bpy.types.Panel):
row.operator("bim.cancel_editing_roof", icon="CANCEL", text="")
draw_roof_properties(self.layout, props)
update_roof_modifier_bmesh(obj)
elif props.is_editing_path:
row.operator("bim.finish_editing_roof_path", icon="CHECKMARK", text="")
row.operator("bim.cancel_editing_roof_path", icon="CANCEL", text="")
+166 -235
View File
@@ -39,47 +39,14 @@ import ifcopenshell.util.shape_builder
import ifcopenshell.util.unit
from bmesh.types import BMVert
from mathutils import Vector, Matrix
from typing import get_args
from typing import get_args, TYPE_CHECKING
if TYPE_CHECKING:
from bonsai.bim.module.model.prop import BIMWindowProperties
V_ = tool.Blender.V_
# Window type visibility helpers for dimension gizmos
_MULLION_TYPES = frozenset((
"DOUBLE_PANEL_VERTICAL",
"TRIPLE_PANEL_BOTTOM",
"TRIPLE_PANEL_TOP",
"TRIPLE_PANEL_LEFT",
"TRIPLE_PANEL_RIGHT",
"TRIPLE_PANEL_VERTICAL",
))
_TRANSOM_TYPES = frozenset((
"DOUBLE_PANEL_HORIZONTAL",
"TRIPLE_PANEL_BOTTOM",
"TRIPLE_PANEL_TOP",
"TRIPLE_PANEL_LEFT",
"TRIPLE_PANEL_RIGHT",
"TRIPLE_PANEL_HORIZONTAL",
))
def _has_mullion(props) -> bool:
"""Check if the window type uses mullions (vertical dividers)."""
return props.window_type in _MULLION_TYPES
def _has_second_mullion(props) -> bool:
"""Check if the window type uses a second mullion."""
return props.window_type == "TRIPLE_PANEL_VERTICAL"
def _has_transom(props) -> bool:
"""Check if the window type uses transoms (horizontal dividers)."""
return props.window_type in _TRANSOM_TYPES
def _has_second_transom(props) -> bool:
"""Check if the window type uses a second transom."""
return props.window_type == "TRIPLE_PANEL_HORIZONTAL"
# Shorthand for gizmo offset constants used in DimensionGizmoConfig lambdas
_G = gizmo.BaseParametricGizmoGroup
def update_window_modifier_representation(context: bpy.types.Context) -> None:
@@ -488,7 +455,7 @@ class AddWindow(bpy.types.Operator, tool.Ifc.Operator):
bl_description = "Add Bonsai parametric window to the active IFC element"
bl_options = {"REGISTER", "UNDO"}
def _execute(self, context):
def _execute(self, context: bpy.types.Context) -> set[str]:
obj = context.active_object
assert obj
element = tool.Ifc.get_entity(obj)
@@ -522,7 +489,7 @@ class CancelEditingWindow(bpy.types.Operator, tool.Ifc.Operator):
bl_description = "Cancel editing and revert window parameters to their previous values"
bl_options = {"REGISTER"}
def _execute(self, context):
def _execute(self, context: bpy.types.Context) -> set[str]:
obj = context.active_object
assert obj
element = tool.Ifc.get_entity(obj)
@@ -551,7 +518,7 @@ class FinishEditingWindow(bpy.types.Operator, tool.Ifc.Operator):
bl_description = "Apply changes and finish editing window parameters"
bl_options = {"REGISTER"}
def _execute(self, context):
def _execute(self, context: bpy.types.Context) -> set[str]:
obj = context.active_object
assert obj
element = tool.Ifc.get_entity(obj)
@@ -584,7 +551,7 @@ class EnableEditingWindow(bpy.types.Operator, tool.Ifc.Operator):
bl_description = "Enter edit mode to modify window parameters interactively"
bl_options = {"REGISTER"}
def _execute(self, context):
def _execute(self, context: bpy.types.Context) -> set[str]:
obj = context.active_object
assert obj
props = tool.Model.get_window_props(obj)
@@ -607,7 +574,7 @@ class RemoveWindow(bpy.types.Operator, tool.Ifc.Operator):
bl_label = "Remove Window"
bl_options = {"REGISTER"}
def _execute(self, context):
def _execute(self, context: bpy.types.Context) -> set[str]: # noqa: ARG002
obj = context.active_object
assert obj
element = tool.Ifc.get_entity(obj)
@@ -621,49 +588,46 @@ class RemoveWindow(bpy.types.Operator, tool.Ifc.Operator):
return {"FINISHED"}
class CycleWindowType(bpy.types.Operator, tool.Ifc.Operator):
"""Cycle through available window types."""
class CycleWindowType(bpy.types.Operator, tool.Ifc.Operator, gizmo.CycleTypeMixin):
"""Cycle through available window types. Shift+click to cycle in reverse."""
bl_idname = "bim.cycle_window_type"
bl_label = "Cycle Window Type"
bl_options = {"REGISTER", "UNDO"}
def _execute(self, context):
obj = tool.Blender.get_active_object()
if not obj:
return {"CANCELLED"}
element_checker = "is_window"
props_getter = "get_window_props"
type_literal = tool.Model.WindowType
type_attr = "window_type"
element = tool.Ifc.get_entity(obj)
if not element or not tool.Blender.Modifier.is_window(element):
return {"CANCELLED"}
props = tool.Model.get_window_props(obj)
window_types = list(get_args(tool.Model.WindowType))
current_index = window_types.index(props.window_type) if props.window_type in window_types else 0
next_index = (current_index + 1) % len(window_types)
props.window_type = window_types[next_index]
return {"FINISHED"}
def _execute(self, context: bpy.types.Context) -> set[str]:
return self._cycle_type(context)
def _compute_frame_depth(props) -> float:
"""Get the first panel's frame depth value."""
return props.frame_depth[0]
# Frame accessor factory - creates callbacks that delegate to BIMWindowProperties methods
def _make_frame_accessors(
attr_name: str, panel_index: int
) -> tuple["collections.abc.Callable[[BIMWindowProperties], float]", "collections.abc.Callable[[BIMWindowProperties, float], None]"]:
"""Create compute/apply callbacks for frame properties at a specific panel index.
Args:
attr_name: Property name ("frame_depth" or "frame_thickness")
panel_index: Panel index (0, 1, or 2)
Returns:
Tuple of (compute_fn, apply_fn) that delegate to BIMWindowProperties methods
"""
return (
lambda props: props.get_frame_value(attr_name, panel_index),
lambda props, value: props.set_frame_value(attr_name, panel_index, value),
)
def _apply_frame_depth(props, value: float) -> None:
"""Apply a new frame depth value to the first panel, preserving other panels."""
props.frame_depth = (max(0.0, value),) + tuple(props.frame_depth[1:])
def _compute_frame_thickness(props) -> float:
"""Get the first panel's frame thickness value."""
return props.frame_thickness[0]
def _apply_frame_thickness(props, value: float) -> None:
"""Apply a new frame thickness value to the first panel, preserving other panels."""
props.frame_thickness = (max(0.0, value),) + tuple(props.frame_thickness[1:])
_frame_accessors = {
(attr, idx): _make_frame_accessors(attr, idx)
for attr in ("frame_depth", "frame_thickness")
for idx in range(3)
}
class GizmoWindowEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
@@ -678,180 +642,147 @@ class GizmoWindowEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
cancel_editing_operator = "bim.cancel_editing_window"
cycle_type_operator = "bim.cycle_window_type"
# matrix_position lambdas replace the get_dimension_matrix_* methods
dimension_gizmo_props = [
DimensionGizmoConfig(attr_name="overall_width", axis=(1, 0, 0), min_value=0.01, text_offset_sign=-1),
DimensionGizmoConfig(attr_name="overall_height", axis=(0, 0, 1), min_value=0.01, text_alignment="start"),
DimensionGizmoConfig(attr_name="lining_offset", axis=(0, 1, 0)),
DimensionGizmoConfig(attr_name="lining_depth", axis=(0, 1, 0)),
DimensionGizmoConfig(attr_name="lining_thickness", axis=(1, 0, 0)),
DimensionGizmoConfig(attr_name="lining_to_panel_offset_x", axis=(1, 0, 0)),
DimensionGizmoConfig(attr_name="lining_to_panel_offset_y", axis=(0, 1, 0), min_value=-10.0),
DimensionGizmoConfig(
attr_name="frame_depth",
axis=(0, -1, 0),
compute_value=_compute_frame_depth,
apply_value=_apply_frame_depth,
attr_name="overall_width", axis=(1, 0, 0), min_value=0.01, text_offset_sign=-1,
matrix_position=lambda p: V_(0, p.lining_offset - _G.GIZMO_OFFSET, -_G.GIZMO_OFFSET),
),
DimensionGizmoConfig(
attr_name="frame_thickness",
axis=(1, 0, 0),
compute_value=_compute_frame_thickness,
apply_value=_apply_frame_thickness,
attr_name="overall_height", axis=(0, 0, 1), min_value=0.01, text_alignment="start",
matrix_position=lambda p: V_(p.overall_width + _G.GIZMO_OFFSET, p.lining_offset - _G.GIZMO_OFFSET, 0),
),
DimensionGizmoConfig(attr_name="mullion_thickness", axis=(1, 0, 0), delta_scale=2.0, visibility_condition=_has_mullion),
DimensionGizmoConfig(attr_name="first_mullion_offset", axis=(1, 0, 0), visibility_condition=_has_mullion),
DimensionGizmoConfig(attr_name="second_mullion_offset", axis=(1, 0, 0), visibility_condition=_has_second_mullion),
DimensionGizmoConfig(attr_name="transom_thickness", axis=(0, 0, 1), delta_scale=2.0, visibility_condition=_has_transom),
DimensionGizmoConfig(attr_name="first_transom_offset", axis=(0, 0, 1), visibility_condition=_has_transom),
DimensionGizmoConfig(attr_name="second_transom_offset", axis=(0, 0, 1), visibility_condition=_has_second_transom),
DimensionGizmoConfig(
attr_name="lining_depth", axis=(0, 1, 0),
matrix_position=lambda p: V_(p.overall_width / 2, p.lining_offset, p.overall_height),
),
DimensionGizmoConfig(
attr_name="lining_thickness", axis=(1, 0, 0),
matrix_position=lambda p: V_(0, p.lining_depth / 2 + p.lining_offset, p.overall_height / 2),
),
DimensionGizmoConfig(
attr_name="lining_to_panel_offset_x", axis=(1, 0, 0),
matrix_position=lambda p: V_(
0,
p.get_lining_to_panel_offset_y_full() + p.frame_depth[0] + p.lining_offset,
p.lining_to_panel_offset_x
),
),
DimensionGizmoConfig(
attr_name="lining_to_panel_offset_y", axis=(0, 1, 0), min_value=-10.0,
matrix_position=lambda p: V_(
p.overall_width - p.lining_to_panel_offset_x,
p.lining_depth + p.lining_offset,
p.lining_to_panel_offset_x
),
),
DimensionGizmoConfig(
attr_name="frame_depth", axis=(0, -1, 0),
compute_value=_frame_accessors[("frame_depth", 0)][0],
apply_value=_frame_accessors[("frame_depth", 0)][1],
matrix_position=lambda p: p.get_frame_position(0, is_depth=True),
),
DimensionGizmoConfig(
attr_name="frame_thickness", axis=(1, 0, 0),
compute_value=_frame_accessors[("frame_thickness", 0)][0],
apply_value=_frame_accessors[("frame_thickness", 0)][1],
matrix_position=lambda p: p.get_frame_position(0, is_depth=False),
),
DimensionGizmoConfig(
attr_name="second_frame_depth", axis=(0, -1, 0),
compute_value=_frame_accessors[("frame_depth", 1)][0],
apply_value=_frame_accessors[("frame_depth", 1)][1],
visibility_condition=lambda p: p.has_second_panel(),
matrix_position=lambda p: p.get_frame_position(1, is_depth=True),
),
DimensionGizmoConfig(
attr_name="second_frame_thickness", axis=(1, 0, 0),
compute_value=_frame_accessors[("frame_thickness", 1)][0],
apply_value=_frame_accessors[("frame_thickness", 1)][1],
visibility_condition=lambda p: p.has_second_panel(),
matrix_position=lambda p: p.get_frame_position(1, is_depth=False),
),
DimensionGizmoConfig(
attr_name="third_frame_depth", axis=(0, -1, 0),
compute_value=_frame_accessors[("frame_depth", 2)][0],
apply_value=_frame_accessors[("frame_depth", 2)][1],
visibility_condition=lambda p: p.has_third_panel(),
matrix_position=lambda p: p.get_frame_position(2, is_depth=True),
),
DimensionGizmoConfig(
attr_name="third_frame_thickness", axis=(1, 0, 0),
compute_value=_frame_accessors[("frame_thickness", 2)][0],
apply_value=_frame_accessors[("frame_thickness", 2)][1],
visibility_condition=lambda p: p.has_third_panel(),
matrix_position=lambda p: p.get_frame_position(2, is_depth=False),
),
DimensionGizmoConfig(
attr_name="mullion_thickness", axis=(1, 0, 0), delta_scale=2.0,
visibility_condition=lambda p: p.has_mullion(),
matrix_position=lambda p: V_(
p.first_mullion_offset - p.mullion_thickness / 2,
p.lining_offset,
p.overall_height / 2 + 3 * _G.GIZMO_STACK_OFFSET
),
),
DimensionGizmoConfig(
attr_name="first_mullion_offset", axis=(1, 0, 0),
visibility_condition=lambda p: p.has_mullion(),
matrix_position=lambda p: V_(0, p.lining_offset, p.overall_height / 2 + _G.GIZMO_STACK_OFFSET),
),
DimensionGizmoConfig(
attr_name="second_mullion_offset", axis=(1, 0, 0),
visibility_condition=lambda p: p.has_second_mullion(),
matrix_position=lambda p: V_(0, p.lining_offset, p.overall_height / 2 + 2 * _G.GIZMO_STACK_OFFSET),
),
DimensionGizmoConfig(
attr_name="transom_thickness", axis=(0, 0, 1), delta_scale=2.0,
visibility_condition=lambda p: p.has_transom(),
matrix_position=lambda p: V_(
p.overall_width / 2 + 2 * _G.GIZMO_STACK_OFFSET,
p.lining_offset,
p.first_transom_offset - p.transom_thickness / 2
),
),
DimensionGizmoConfig(
attr_name="first_transom_offset", axis=(0, 0, 1),
visibility_condition=lambda p: p.has_transom(),
matrix_position=lambda p: V_(p.overall_width / 2, p.lining_offset, 0),
),
DimensionGizmoConfig(
attr_name="second_transom_offset", axis=(0, 0, 1),
visibility_condition=lambda p: p.has_second_transom(),
matrix_position=lambda p: V_(p.overall_width / 2 + _G.GIZMO_STACK_OFFSET, p.lining_offset, 0),
),
# lining_offset is handled specially in _update_dimension_gizmo_positions due to negative value support
DimensionGizmoConfig(attr_name="lining_offset", axis=(0, 1, 0), min_value=-10.0),
]
props_getter = "get_window_props"
gizmo_pref_name = "window"
@classmethod
def is_element_type(cls, element) -> bool:
def is_element_type(cls, element: ifcopenshell.entity_instance) -> bool:
return tool.Blender.Modifier.is_window(element)
def get_props(self, obj: bpy.types.Object):
return tool.Model.get_window_props(obj)
def get_gizmo_prefs(self):
prefs = tool.Blender.get_addon_preferences()
return prefs.gizmos.window
def get_icon_y_offset(self, context: bpy.types.Context, mw: Matrix) -> float:
"""Position icons beyond the furthest geometry extent based on view direction."""
obj = context.active_object
if not obj:
return self.ICON_Y_OFFSET
props = self.get_props(obj)
def get_icon_y_extent(self, props: "BIMWindowProperties") -> tuple[float, float]:
"""Get Y extents for window icon positioning.
Window geometry can extend asymmetrically in +Y and -Y directions
depending on lining_offset (which can be negative).
"""
furthest_positive_y = (
max(0, props.lining_offset)
+ props.lining_depth
+ props.lining_to_panel_offset_y
+ 2 * self.GIZMO_OFFSET
)
furthest_negative_y = min(0, props.lining_offset)
furthest_negative_y = abs(min(0, props.lining_offset)) + 2 * self.GIZMO_OFFSET
return (furthest_positive_y, furthest_negative_y)
viewing_from_negative_y, _ = self.get_local_view_direction(context, mw)
if viewing_from_negative_y:
return furthest_negative_y - 2 * self.GIZMO_OFFSET
return furthest_positive_y + 2 * self.GIZMO_OFFSET
# Window uses base class setup() and refresh() - no element-specific gizmos needed
def get_dimension_matrix_lining_offset(self, props) -> Matrix:
return self.compose_gizmo_matrix(V_(0, 0, 0), (0, 1, 0))
def get_dimension_matrix_lining_depth(self, props) -> Matrix:
return self.compose_gizmo_matrix(
V_(props.overall_width / 2, props.lining_offset, props.overall_height), (0, 1, 0)
)
def get_dimension_matrix_lining_thickness(self, props) -> Matrix:
return self.compose_gizmo_matrix(
V_(0, props.lining_depth / 2 + props.lining_offset, props.overall_height / 2), (1, 0, 0)
)
@staticmethod
def _get_lining_to_panel_offset_y_full(props) -> float:
"""Get the full Y offset for lining-to-panel positioning."""
return (props.lining_depth - props.frame_depth[0]) + props.lining_to_panel_offset_y
def get_dimension_matrix_lining_to_panel_offset_x(self, props) -> Matrix:
y_full = self._get_lining_to_panel_offset_y_full(props)
return self.compose_gizmo_matrix(
V_(0, y_full + props.frame_depth[0] + props.lining_offset, props.lining_to_panel_offset_x), (1, 0, 0)
)
def get_dimension_matrix_lining_to_panel_offset_y(self, props) -> Matrix:
y_start = props.lining_depth + props.lining_offset
return self.compose_gizmo_matrix(
V_(props.overall_width - props.lining_to_panel_offset_x, y_start, props.lining_to_panel_offset_x),
(0, 1, 0),
)
def get_dimension_matrix_frame_depth(self, props) -> Matrix:
y_full = self._get_lining_to_panel_offset_y_full(props)
y_start = y_full + props.frame_depth[0] + props.lining_offset
return self.compose_gizmo_matrix(
V_(props.lining_to_panel_offset_x, y_start, props.lining_to_panel_offset_x), (0, -1, 0)
)
def get_dimension_matrix_frame_thickness(self, props) -> Matrix:
y_full = self._get_lining_to_panel_offset_y_full(props)
y_pos = y_full + props.frame_depth[0] + props.lining_offset
return self.compose_gizmo_matrix(
V_(props.lining_to_panel_offset_x, y_pos, props.overall_height / 2), (1, 0, 0)
)
def get_dimension_matrix_mullion_thickness(self, props) -> Matrix:
return self.compose_gizmo_matrix(
V_(props.first_mullion_offset - props.mullion_thickness / 2, props.lining_offset, props.overall_height / 2),
(1, 0, 0),
)
def get_dimension_matrix_first_mullion_offset(self, props) -> Matrix:
return self.compose_gizmo_matrix(
V_(0, props.lining_offset, props.overall_height / 2), (1, 0, 0)
)
def get_dimension_matrix_second_mullion_offset(self, props) -> Matrix:
return self.compose_gizmo_matrix(
V_(0, props.lining_offset, props.overall_height / 2 + 0.1), (1, 0, 0)
)
def get_dimension_matrix_transom_thickness(self, props) -> Matrix:
# Offset X when panels are horizontal to avoid overlap with mullion gizmo
x_pos = props.overall_width / 2 - 0.1 if _has_transom(props) else props.overall_width / 2
return self.compose_gizmo_matrix(
V_(x_pos, props.lining_offset, props.first_transom_offset - props.transom_thickness / 2),
(0, 0, 1),
)
def get_dimension_matrix_first_transom_offset(self, props) -> Matrix:
return self.compose_gizmo_matrix(
V_(props.overall_width / 2, props.lining_offset, 0), (0, 0, 1)
)
def get_dimension_matrix_second_transom_offset(self, props) -> Matrix:
return self.compose_gizmo_matrix(
V_(props.overall_width / 2 + 0.1, props.lining_offset, 0), (0, 0, 1)
)
def get_dimension_matrix_overall_width(self, props) -> Matrix:
"""Position width dimension below the window."""
return self.compose_gizmo_matrix(
V_(0, props.lining_offset - self.GIZMO_OFFSET, -self.GIZMO_OFFSET), (1, 0, 0)
)
def get_dimension_matrix_overall_height(self, props) -> Matrix:
"""Position height dimension to the side of the window."""
return self.compose_gizmo_matrix(
V_(props.overall_width + self.GIZMO_OFFSET, props.lining_offset - self.GIZMO_OFFSET, 0), (0, 0, 1)
)
def setup(self, context: bpy.types.Context) -> None:
self.setup_editing_gizmos(context)
self.setup_dimension_gizmos(context)
def refresh(self, context: bpy.types.Context) -> None:
if not self.is_setup_complete():
return
obj = context.active_object
if not obj:
return
props = self.get_props(obj)
mw = obj.matrix_world
self.update_editing_gizmos(context, mw, props)
self.update_dimension_gizmos(mw, props)
def _update_dimension_gizmo_positions(self, context: bpy.types.Context, mw: Matrix, props) -> None:
def _update_dimension_gizmo_positions(self, context: bpy.types.Context, mw: Matrix, props: "BIMWindowProperties") -> None:
"""Update dimension gizmo positions based on camera view direction."""
viewing_from_negative_y, viewing_from_negative_x = self.get_local_view_direction(context, mw)
y_pos = self.get_lining_y_position_for_view(props, viewing_from_negative_y)
self.set_dimension_gizmo_position("overall_width", mw, V_(0, y_pos, -self.GIZMO_OFFSET), (1, 0, 0))
if viewing_from_negative_x:
x_pos = -self.GIZMO_OFFSET
else:
x_pos = props.overall_width + self.GIZMO_OFFSET
self.set_dimension_gizmo_position("overall_height", mw, V_(x_pos, y_pos, 0), (0, 0, 1))
# Window uses base implementation with default casing_offset=0
self._update_view_dependent_dimensions(context, mw, props)