Major refactor of parametric gizmo system: dimension lines and code consolidation

Major changes:
- Relocate gizmo infrastructure from bim/gizmo.py to module/drawing/gizmos.py
- Replace arrow-based property gizmos with dimension line gizmos throughout
- Add view-dependent positioning: gizmos automatically reposition based on camera angle to stay visible and avoid overlapping geometry
- Add special icons for stair to deal with edge cases

Code quality:
- Add DRY helper methods for gizmo positioning across stair, door, and window
- Remove redundant visibility logic
- Extract integer input handling to dedicated module for reuse
- Add comprehensive documentation for gizmo architecture
This commit is contained in:
Gorgious
2025-11-30 21:55:36 +01:00
parent b7a9199253
commit ad4fc76546
10 changed files with 4528 additions and 2560 deletions
+1 -14
View File
@@ -21,7 +21,7 @@ import bpy
import bpy.utils.previews import bpy.utils.previews
import importlib import importlib
from bpy_extras.io_utils import ImportHelper, ExportHelper from bpy_extras.io_utils import ImportHelper, ExportHelper
from . import handler, ui, prop, operator, gizmo from . import handler, ui, prop, operator
from typing import Union from typing import Union
from collections.abc import Callable from collections.abc import Callable
@@ -206,19 +206,6 @@ classes = [
ui.BIM_PT_section_with_cappings, ui.BIM_PT_section_with_cappings,
ui.BIM_PT_decorators_overlay, ui.BIM_PT_decorators_overlay,
ui.BIM_PT_snappping, ui.BIM_PT_snappping,
# Gizmos
gizmo.BIM_OT_gizmo_value_input,
gizmo.GizmoArrow,
gizmo.GizmoArrow2D,
gizmo.GizmoCone,
gizmo.GizmoLock,
gizmo.GizmoArc,
gizmo.GizmoPen,
gizmo.GizmoValidate,
gizmo.GizmoCancel,
gizmo.GizmoPlus,
gizmo.GizmoMinus,
gizmo.GizmoCycle,
] ]
for mod in modules.values(): for mod in modules.values():
File diff suppressed because it is too large Load Diff
@@ -111,9 +111,22 @@ classes = (
ui.BIM_PT_text, ui.BIM_PT_text,
ui.BIM_UL_drawinglist, ui.BIM_UL_drawinglist,
ui.BIM_UL_sheets, ui.BIM_UL_sheets,
# Core gizmos (shared across modules)
gizmos.BIM_OT_gizmo_value_input,
gizmos.GizmoArrow,
gizmos.GizmoArrow2D,
gizmos.GizmoCone,
gizmos.GizmoDimension,
gizmos.GizmoLock,
gizmos.GizmoArc,
gizmos.GizmoPen,
gizmos.GizmoValidate,
gizmos.GizmoCancel,
gizmos.GizmoPlus,
gizmos.GizmoMinus,
gizmos.GizmoCycle,
# Drawing-specific gizmos
gizmos.UglyDotGizmo, gizmos.UglyDotGizmo,
gizmos.DotGizmo,
gizmos.DimensionLabelGizmo,
gizmos.ExtrusionGuidesGizmo, gizmos.ExtrusionGuidesGizmo,
gizmos.ExtrusionWidget, gizmos.ExtrusionWidget,
workspace.LaunchAnnotationTypeManager, workspace.LaunchAnnotationTypeManager,
File diff suppressed because it is too large Load Diff
@@ -162,7 +162,9 @@ classes = (
stair.EnableEditingStair, stair.EnableEditingStair,
stair.RemoveStair, stair.RemoveStair,
stair.ToggleStairTotalLengthLock, stair.ToggleStairTotalLengthLock,
stair.ToggleStairCustomTreadLock,
stair.AdjustStairTreads, stair.AdjustStairTreads,
stair.SetStairTreads,
stair.CycleStairType, stair.CycleStairType,
stair.GizmoStairEdition, stair.GizmoStairEdition,
sverchok_modifier.CreateNewSverchokGraph, sverchok_modifier.CreateNewSverchokGraph,
+121 -85
View File
@@ -33,8 +33,8 @@ import bonsai.core.geometry
import bonsai.core.geometry as core import bonsai.core.geometry as core
import bonsai.core.root import bonsai.core.root
from bonsai.bim.module.model.window import create_bm_window, create_bm_box from bonsai.bim.module.model.window import create_bm_window, create_bm_box
from bonsai.bim import gizmo from bonsai.bim.module.drawing import gizmos as gizmo
from bonsai.bim.gizmo import GizmoPropConfig from bonsai.bim.module.drawing.gizmos import DimensionGizmoConfig
from mathutils import Vector, Matrix from mathutils import Vector, Matrix
@@ -786,31 +786,33 @@ class GizmoDoorEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
cancel_editing_operator = "bim.cancel_editing_door" cancel_editing_operator = "bim.cancel_editing_door"
cycle_type_operator = "bim.cycle_door_type" cycle_type_operator = "bim.cycle_door_type"
gizmo_props = [ dimension_gizmo_props = [
GizmoPropConfig("overall_height", (0, 0, 1)), DimensionGizmoConfig(attr_name="overall_width", axis=(1, 0, 0), min_value=0.01, text_offset_sign=-1),
GizmoPropConfig("overall_width", (1, 0, 0)), DimensionGizmoConfig(attr_name="overall_height", axis=(0, 0, 1), min_value=0.01, text_alignment="start"),
GizmoPropConfig("threshold_thickness", (0, 0, 1)), DimensionGizmoConfig(attr_name="threshold_thickness", axis=(0, 0, 1)),
GizmoPropConfig("threshold_depth", (0, 1, 0)), DimensionGizmoConfig(attr_name="threshold_depth", axis=(0, 1, 0)),
GizmoPropConfig("lining_depth", (0, 1, 0)), DimensionGizmoConfig(attr_name="threshold_offset", axis=(0, 1, 0)),
GizmoPropConfig("lining_thickness", (-1, 0, 0)), DimensionGizmoConfig(attr_name="lining_offset", axis=(0, 1, 0)),
GizmoPropConfig("transom_offset", (0, 0, 1)), DimensionGizmoConfig(attr_name="lining_depth", axis=(0, 1, 0)),
GizmoPropConfig("transom_thickness", (0, 0, 1)), DimensionGizmoConfig(attr_name="lining_thickness", axis=(-1, 0, 0)),
GizmoPropConfig("casing_thickness", (-1, 0, 0)), DimensionGizmoConfig(attr_name="transom_offset", axis=(0, 0, 1)),
GizmoPropConfig("casing_depth", (0, 1, 0)), 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)),
] ]
@classmethod @classmethod
def is_element_type(cls, element) -> bool: def is_element_type(cls, element) -> bool:
return tool.Blender.Modifier.is_door(element) return tool.Blender.Modifier.is_door(element)
def get_props(self, obj): def get_props(self, obj: bpy.types.Object):
return tool.Model.get_door_props(obj) return tool.Model.get_door_props(obj)
def get_gizmo_prefs(self): def get_gizmo_prefs(self):
prefs = tool.Blender.get_addon_preferences() prefs = tool.Blender.get_addon_preferences()
return prefs.gizmos.door return prefs.gizmos.door
def should_hide_gizmo(self, attr_name, props): def should_hide_gizmo(self, attr_name: str, props) -> bool:
"""Door-specific visibility rules for gizmos.""" """Door-specific visibility rules for gizmos."""
if not props.is_editing: if not props.is_editing:
return True return True
@@ -824,90 +826,100 @@ class GizmoDoorEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
return True return True
return False return False
def get_gizmo_matrix_overall_height(self, props): def get_icon_y_offset(self, context: bpy.types.Context, mw: Matrix) -> float:
translation = Matrix.Translation(V_(props.overall_width - 0.05, props.lining_offset, props.overall_height)) """Get Y offset for icons based on view direction.
rotation = self.get_axis_rotation_matrix((0, 0, 1))
return translation @ rotation
def get_gizmo_matrix_overall_width(self, props): Positions icons further than the furthest geometry:
translation = Matrix.Translation(V_(props.overall_width, props.lining_offset, props.overall_height - 0.05)) max(threshold_offset + threshold_depth, lining_offset + lining_depth) + 2 * GIZMO_OFFSET
rotation = self.get_axis_rotation_matrix((1, 0, 0)) """
return translation @ rotation obj = context.active_object
if not obj:
def get_gizmo_matrix_threshold_thickness(self, props): return self.ICON_Y_OFFSET
translation = Matrix.Translation( props = self.get_props(obj)
V_( furthest_y = (
props.overall_width / 2, max(
props.threshold_offset + props.threshold_depth / 2 + props.lining_offset, props.threshold_offset + props.threshold_depth,
props.threshold_thickness, props.lining_offset + props.lining_depth,
) )
+ 2 * self.GIZMO_OFFSET
) )
rotation = self.get_axis_rotation_matrix((0, 0, 1))
return translation @ rotation
def get_gizmo_matrix_threshold_depth(self, props): viewing_from_negative_y, _ = self.get_local_view_direction(context, mw)
translation = Matrix.Translation( if viewing_from_negative_y:
V_(props.overall_width / 2, props.threshold_depth + props.lining_offset, props.threshold_thickness / 2) return -furthest_y
) return furthest_y
rotation = self.get_axis_rotation_matrix((0, 1, 0))
return translation @ rotation
def get_gizmo_matrix_lining_depth(self, props): def get_dimension_matrix_threshold_thickness(self, props) -> Matrix:
translation = Matrix.Translation( return self.compose_gizmo_matrix(
V_(props.overall_width / 2, props.lining_depth + props.lining_offset, props.overall_height) V_(props.overall_width / 2, props.threshold_offset + props.threshold_depth, 0), (0, 0, 1)
) )
rotation = self.get_axis_rotation_matrix((0, 1, 0))
return translation @ rotation
def get_gizmo_matrix_lining_thickness(self, props): def get_dimension_matrix_threshold_depth(self, props) -> Matrix:
translation = Matrix.Translation( return self.compose_gizmo_matrix(
V_(props.overall_width - props.lining_thickness, props.lining_depth / 2, props.overall_height / 2) V_(props.overall_width / 2, props.threshold_offset, props.threshold_thickness), (0, 1, 0)
) )
rotation = self.get_axis_rotation_matrix((-1, 0, 0))
return translation @ rotation
def get_gizmo_matrix_transom_offset(self, props): def get_dimension_matrix_threshold_offset(self, props) -> Matrix:
# Position at the bottom of the transom (transom extends upward from offset) return self.compose_gizmo_matrix(
translation = Matrix.Translation( V_(props.overall_width / 2 - 0.1, 0, props.threshold_thickness), (0, 1, 0)
V_(props.overall_width / 2, props.lining_offset, props.transom_offset)
) )
rotation = self.get_axis_rotation_matrix((0, 0, 1))
return translation @ rotation
def get_gizmo_matrix_transom_thickness(self, props): def get_dimension_matrix_lining_offset(self, props) -> Matrix:
# Position at the top of the transom (offset + thickness) return self.compose_gizmo_matrix(V_(0, 0, 0), (0, 1, 0))
translation = Matrix.Translation(
V_(props.overall_width / 2, props.lining_offset, props.transom_offset + props.transom_thickness) 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)
) )
rotation = self.get_axis_rotation_matrix((0, 0, 1))
return translation @ rotation
@staticmethod @staticmethod
def _get_casing_gizmo_base_position(props) -> tuple[float, float, float]: def _get_casing_gizmo_base_position(props) -> tuple[float, float, float]:
"""Get common base position for casing gizmos.""" """Get common base position for casing gizmos."""
x = -props.casing_thickness + props.lining_thickness x = props.lining_thickness
y_base = props.lining_depth + props.lining_offset y_base = props.lining_depth + props.lining_offset
z = props.overall_height / 2 z = props.overall_height / 2
return x, y_base, z return x, y_base, z
def get_gizmo_matrix_casing_thickness(self, props): def get_dimension_matrix_casing_thickness(self, props) -> Matrix:
x, y_base, z = self._get_casing_gizmo_base_position(props) x, y_base, z = self._get_casing_gizmo_base_position(props)
translation = Matrix.Translation(V_(x, y_base + props.casing_depth / 2, z)) return self.compose_gizmo_matrix(V_(x, y_base + props.casing_depth / 2, z), (-1, 0, 0))
rotation = self.get_axis_rotation_matrix((-1, 0, 0))
return translation @ rotation
def get_gizmo_matrix_casing_depth(self, props): def get_dimension_matrix_casing_depth(self, props) -> Matrix:
x, y_base, z = self._get_casing_gizmo_base_position(props) x, y_base, z = self._get_casing_gizmo_base_position(props)
translation = Matrix.Translation(V_(x, y_base + props.casing_depth, z)) return self.compose_gizmo_matrix(V_(x - props.casing_thickness, y_base, z), (0, 1, 0))
rotation = self.get_axis_rotation_matrix((0, 1, 0))
return translation @ rotation
def setup(self, context): def get_dimension_matrix_overall_width(self, props) -> Matrix:
# Use base class methods for common gizmos """Position width dimension below the door."""
self.setup_property_gizmos(context) 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_editing_gizmos(context)
self.setup_dimension_gizmos(context)
# Door-specific: swing arc gizmos
prefs = tool.Blender.get_addon_preferences() prefs = tool.Blender.get_addon_preferences()
highlight_color = prefs.decorator_color_selected[:3] highlight_color = prefs.decorator_color_selected[:3]
inactive_color = prefs.decorator_color_background[:3] inactive_color = prefs.decorator_color_background[:3]
@@ -922,7 +934,6 @@ class GizmoDoorEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
op = self.gizmo_door_type.target_set_operator("bim.toggle_door_swing") op = self.gizmo_door_type.target_set_operator("bim.toggle_door_swing")
op.flip_geometry = False op.flip_geometry = False
# Flip arc gizmo - mirrors the swing arc along X axis, flips door and toggles direction when clicked
self.gizmo_flip_arc = self.gizmos.new("VIEW3D_GT_arc") self.gizmo_flip_arc = self.gizmos.new("VIEW3D_GT_arc")
self.gizmo_flip_arc.use_draw_scale = False self.gizmo_flip_arc.use_draw_scale = False
self.gizmo_flip_arc.color = inactive_color self.gizmo_flip_arc.color = inactive_color
@@ -933,40 +944,65 @@ class GizmoDoorEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
op.flip_geometry = True op.flip_geometry = True
op.flip_local_axes = "XY" op.flip_local_axes = "XY"
def refresh(self, context): def refresh(self, context: bpy.types.Context) -> None:
if not self.is_setup_complete():
return
obj = context.active_object obj = context.active_object
if not obj: if not obj:
return return
props = self.get_props(obj) props = self.get_props(obj)
mw = obj.matrix_world mw = obj.matrix_world
self.update_property_gizmos(mw, props)
self.update_swing_gizmos(mw, props) self.update_swing_gizmos(mw, props)
self.update_editing_gizmos(context, mw, props) self.update_editing_gizmos(context, mw, props)
self.update_dimension_gizmos(mw, props)
def update_swing_gizmos(self, mw, props): def _update_dimension_gizmo_positions(self, context: bpy.types.Context, mw: Matrix, props) -> 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))
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:
"""Update swing gizmo position and color based on editing state.""" """Update swing gizmo position and color based on editing state."""
prefs = tool.Blender.get_addon_preferences() door_type_hidden_by_modal = self.is_gizmo_hidden_by_modal(self.gizmo_door_type)
door_gizmo_prefs = prefs.gizmos.door flip_arc_hidden_by_modal = self.is_gizmo_hidden_by_modal(self.gizmo_flip_arc)
self.gizmo_door_type.hide = not props.is_editing or not door_gizmo_prefs.swing_arc if door_type_hidden_by_modal:
self.gizmo_flip_arc.hide = not props.is_editing or not door_gizmo_prefs.flip_arc 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
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 both are hidden, no need to calculate transforms
if self.gizmo_door_type.hide and self.gizmo_flip_arc.hide: if self.gizmo_door_type.hide and self.gizmo_flip_arc.hide:
return return
# Calculate base swing transformation (common to both arcs)
swing_x_offset = props.overall_width if "RIGHT" in props.door_type else 0.0 swing_x_offset = props.overall_width if "RIGHT" in props.door_type else 0.0
base_swing_transform = Matrix.Translation(V_(swing_x_offset, props.lining_offset, 0)) @ Matrix.Scale( base_swing_transform = Matrix.Translation(V_(swing_x_offset, props.lining_offset, 0)) @ Matrix.Scale(
props.overall_width, 4 props.overall_width, 4
) )
prefs = tool.Blender.get_addon_preferences()
if not self.gizmo_door_type.hide: if not self.gizmo_door_type.hide:
self.gizmo_door_type.matrix_basis = mw @ base_swing_transform self.gizmo_door_type.matrix_basis = mw @ base_swing_transform
self.gizmo_door_type.color = prefs.decorations_colour[:3] self.gizmo_door_type.color = prefs.decorations_colour[:3]
if not self.gizmo_flip_arc.hide: if not self.gizmo_flip_arc.hide:
# Mirror the flip arc along Y axis to show the opposite swing direction
mirror_y = Matrix.Scale(-1, 4, (0, 1, 0)) mirror_y = Matrix.Scale(-1, 4, (0, 1, 0))
self.gizmo_flip_arc.matrix_basis = mw @ base_swing_transform @ mirror_y self.gizmo_flip_arc.matrix_basis = mw @ base_swing_transform @ mirror_y
+392 -210
View File
@@ -27,9 +27,12 @@ import ifcopenshell.util.representation
import ifcopenshell.util.unit import ifcopenshell.util.unit
import bonsai.core.root import bonsai.core.root
import bonsai.tool as tool import bonsai.tool as tool
from bonsai.bim import gizmo from bonsai.bim.module.drawing import gizmos as gizmo
from bonsai.bim.gizmo import GizmoPropConfig from bonsai.bim.module.drawing.gizmos import DimensionGizmoConfig
from bonsai.tool.numeric_input import IntegerInputState, run_integer_input_modal, update_header
from mathutils import Vector, Matrix from mathutils import Vector, Matrix
V_ = tool.Blender.V_
from bmesh.types import BMVert from bmesh.types import BMVert
from bpy.types import Operator from bpy.types import Operator
from bpy.props import FloatProperty, IntProperty from bpy.props import FloatProperty, IntProperty
@@ -218,6 +221,7 @@ class AddStair(bpy.types.Operator, tool.Ifc.Operator):
class CancelEditingStair(bpy.types.Operator, tool.Ifc.Operator): class CancelEditingStair(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.cancel_editing_stair" bl_idname = "bim.cancel_editing_stair"
bl_label = "Cancel Editing Stair" bl_label = "Cancel Editing Stair"
bl_description = "Cancel editing and revert stair parameters to their previous values"
bl_options = {"REGISTER"} bl_options = {"REGISTER"}
def _execute(self, context): def _execute(self, context):
@@ -239,6 +243,7 @@ class CancelEditingStair(bpy.types.Operator, tool.Ifc.Operator):
class FinishEditingStair(bpy.types.Operator, tool.Ifc.Operator): class FinishEditingStair(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.finish_editing_stair" bl_idname = "bim.finish_editing_stair"
bl_label = "Finish Editing Stair" bl_label = "Finish Editing Stair"
bl_description = "Apply changes and finish editing stair parameters"
bl_options = {"REGISTER"} bl_options = {"REGISTER"}
def _execute(self, context): def _execute(self, context):
@@ -266,6 +271,7 @@ class FinishEditingStair(bpy.types.Operator, tool.Ifc.Operator):
class EnableEditingStair(bpy.types.Operator, tool.Ifc.Operator): class EnableEditingStair(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.enable_editing_stair" bl_idname = "bim.enable_editing_stair"
bl_label = "Enable Editing Stair" bl_label = "Enable Editing Stair"
bl_description = "Enter edit mode to modify stair parameters interactively"
bl_options = {"REGISTER"} bl_options = {"REGISTER"}
def _execute(self, context): def _execute(self, context):
@@ -317,8 +323,26 @@ class ToggleStairTotalLengthLock(bpy.types.Operator):
return {"FINISHED"} 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"}
class AdjustStairTreads(bpy.types.Operator): class AdjustStairTreads(bpy.types.Operator):
"""Adjust the number of treads""" """Adjust the number of treads. Shift+click to enter a specific number."""
bl_idname = "bim.adjust_stair_treads" bl_idname = "bim.adjust_stair_treads"
bl_label = "Adjust Stair Treads" bl_label = "Adjust Stair Treads"
@@ -326,6 +350,12 @@ class AdjustStairTreads(bpy.types.Operator):
increment: IntProperty(name="Increment", default=1) increment: IntProperty(name="Increment", default=1)
def invoke(self, context, event):
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):
obj = context.active_object obj = context.active_object
if not obj: if not obj:
@@ -339,6 +369,51 @@ class AdjustStairTreads(bpy.types.Operator):
return {"FINISHED"} return {"FINISHED"}
class SetStairTreads(bpy.types.Operator):
"""Set the number of treads to a specific value."""
bl_idname = "bim.set_stair_treads"
bl_label = "Set Number of Treads"
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
def invoke(self, context, event): # noqa: ARG002
obj = context.active_object
if not obj:
return {"CANCELLED"}
props = tool.Model.get_stair_props(obj)
self._input = IntegerInputState.from_value(props.number_of_treads, min_value=1)
self._original_value = props.number_of_treads
bpy.ops.ed.undo_push(message="Set Number of Treads")
context.window_manager.modal_handler_add(self)
update_header(context, self._format_header())
return {"RUNNING_MODAL"}
def modal(self, context, event):
return run_integer_input_modal(self, context, event)
def _apply_value(self, context) -> None:
obj = context.active_object
if not obj:
return
value = self._input.get_value()
if value is not None:
props = tool.Model.get_stair_props(obj)
props.number_of_treads = value
def _restore_value(self, context) -> None:
obj = context.active_object
if obj:
props = tool.Model.get_stair_props(obj)
props.number_of_treads = self._original_value
def _format_header(self) -> str:
input_str = self._input.get_input_string()
validity = "" if self._input.is_valid else " [must be >= 1]"
return f"Number of Treads: {input_str}_{validity} | Enter to confirm, Esc to cancel"
class CycleStairType(bpy.types.Operator): class CycleStairType(bpy.types.Operator):
"""Cycle through stair types. Shift+click to cycle in reverse.""" """Cycle through stair types. Shift+click to cycle in reverse."""
@@ -366,6 +441,26 @@ class CycleStairType(bpy.types.Operator):
return {"FINISHED"} return {"FINISHED"}
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]
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))
class GizmoStairEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup): class GizmoStairEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
bl_idname = "OBJECT_GGT_bim_stair_edition" bl_idname = "OBJECT_GGT_bim_stair_edition"
bl_label = "Stair Editing Gizmo" bl_label = "Stair Editing Gizmo"
@@ -373,171 +468,197 @@ class GizmoStairEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
bl_region_type = "WINDOW" bl_region_type = "WINDOW"
bl_options = {"3D", "PERSISTENT"} bl_options = {"3D", "PERSISTENT"}
# Gizmo layout offsets (meters) # === Stair-Specific Icon Layout (meters) ===
GIZMO_X_OFFSET = 0.5 # Horizontal offset from stair end # Additional icons for stair editing, positioned after standard icons:
GIZMO_PLUS_X_OFFSET = 0.25 # Additional X offset for plus button # [Validate] [Cancel] [Cycle] [TreadLock] [Plus] [Minus]
GIZMO_MINUS_X_OFFSET = 0.5 # Additional X offset for minus button ICON_TREAD_LOCK_X = 1.24 # X position for tread lock toggle icon
GIZMO_BUTTON_Z_OFFSET = 0.15 # Vertical offset for +/- buttons above lock ICON_PLUS_X = 1.61 # X position for add tread (+) icon
GIZMO_CYCLE_Z_OFFSET = 0.5 # Vertical offset for cycle gizmo above lock ICON_MINUS_X = 1.98 # X position for remove tread (-) icon
ICON_PLUS_MINUS_SCALE = 0.24 # Scale for plus/minus icons (slightly larger)
enable_editing_operator = "bim.enable_editing_stair" enable_editing_operator = "bim.enable_editing_stair"
finish_editing_operator = "bim.finish_editing_stair" finish_editing_operator = "bim.finish_editing_stair"
cancel_editing_operator = "bim.cancel_editing_stair" cancel_editing_operator = "bim.cancel_editing_stair"
cycle_type_operator = "bim.cycle_stair_type"
gizmo_props = [ def get_icon_y_offset(self, context, mw):
GizmoPropConfig("width", (0, 1, 0)), """Get Y offset for icons based on view direction.
GizmoPropConfig("height", (0, 0, 1)),
GizmoPropConfig("tread_run", (1, 0, 0)), Positions icons further than the furthest geometry:
GizmoPropConfig("tread_depth", (0, 0, -1)), stair_width + 2 * GIZMO_OFFSET
GizmoPropConfig("nosing_length", (-1, 0, 0)), """
GizmoPropConfig("nosing_depth", (0, 0, -1)), obj = context.active_object
GizmoPropConfig("total_length_target", (1, 0, 0)), if not obj:
GizmoPropConfig("base_slab_depth", (0, 0, -1)), return self.ICON_Y_OFFSET
GizmoPropConfig("top_slab_depth", (0, 0, -1)), 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
dimension_gizmo_props = [
DimensionGizmoConfig(
attr_name="total_length_target",
axis=(1, 0, 0),
prop_name="Total Length",
min_value=0.01,
text_offset_sign=-1,
),
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,
),
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,
),
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,
),
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",
),
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)),
),
DimensionGizmoConfig(
attr_name="nosing_depth",
axis=(0, 0, -1),
visibility_condition=lambda props: props.nosing_length != 0.0 and props.stair_type != "WOOD/STEEL",
),
DimensionGizmoConfig(
attr_name="base_slab_depth",
axis=(0, 0, -1),
visibility_condition=lambda props: props.stair_type == "CONCRETE",
),
DimensionGizmoConfig(
attr_name="top_slab_depth",
axis=(0, 0, -1),
visibility_condition=lambda props: props.stair_type == "CONCRETE",
),
] ]
@classmethod @classmethod
def is_element_type(cls, element) -> bool: def is_element_type(cls, element) -> bool:
return tool.Blender.Modifier.is_stair(element) return tool.Blender.Modifier.is_stair(element)
def get_props(self, obj): def get_props(self, obj: bpy.types.Object):
return tool.Model.get_stair_props(obj) return tool.Model.get_stair_props(obj)
def get_gizmo_prefs(self): def get_gizmo_prefs(self):
prefs = tool.Blender.get_addon_preferences() prefs = tool.Blender.get_addon_preferences()
return prefs.gizmos.stair return prefs.gizmos.stair
def should_hide_gizmo(self, attr_name, props):
"""Stair-specific visibility rules for gizmos."""
if not props.is_editing:
return True
# Hide concrete-specific gizmos for non-concrete stairs
if attr_name in ("base_slab_depth", "top_slab_depth") and props.stair_type != "CONCRETE":
return True
# Hide tread_depth for generic stairs (has no tread geometry)
if attr_name == "tread_depth" and props.stair_type == "GENERIC":
return True
# Hide nosing_depth when nosing_length is 0 or for Wood/Steel stair types
if attr_name == "nosing_depth" and (props.nosing_length == 0.0 or props.stair_type == "WOOD/STEEL"):
return True
return False
@staticmethod @staticmethod
def _get_stair_total_run(props) -> float: def _get_stair_total_run(props) -> float:
"""Calculate the total horizontal run of the stair.""" """Calculate the total horizontal run of the stair.
return props.tread_run * props.number_of_treads
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 @staticmethod
def _get_first_riser_height(props) -> float: def _get_first_riser_height(props) -> float:
"""Calculate the height of the first riser.""" """Calculate the height of the first riser."""
return props.height / (props.number_of_treads + 1) return props.height / (props.number_of_treads + 1)
def get_gizmo_matrix_width(self, props): def get_dimension_matrix_total_length_target(self, props) -> Matrix:
translation = Matrix.Translation(Vector((0, props.width, 0))) return self.compose_gizmo_matrix(V_(0, -self.GIZMO_OFFSET, -self.GIZMO_OFFSET), (1, 0, 0))
rotation = self.get_axis_rotation_matrix((0, 1, 0))
return translation @ rotation
def get_gizmo_matrix_width_top(self, props): def get_dimension_matrix_height(self, props) -> Matrix:
"""Position for the secondary width gizmo at the top of the stairs."""
total_run = self._get_stair_total_run(props) total_run = self._get_stair_total_run(props)
translation = Matrix.Translation(Vector((total_run, props.width, props.height))) return self.compose_gizmo_matrix(V_(total_run + self.GIZMO_OFFSET, -self.GIZMO_OFFSET, 0), (0, 0, 1))
rotation = self.get_axis_rotation_matrix((0, 1, 0))
return translation @ rotation
def get_gizmo_matrix_height(self, props): 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) total_run = self._get_stair_total_run(props)
translation = Matrix.Translation(Vector((total_run, props.width / 2, props.height))) x_offset = total_run - props.custom_first_last_tread_run[1]
rotation = self.get_axis_rotation_matrix((0, 0, 1)) return self.compose_gizmo_matrix(V_(x_offset, 0, props.height), (1, 0, 0))
return translation @ rotation
def get_gizmo_matrix_tread_run(self, props): def get_dimension_matrix_nosing_length(self, props) -> Matrix:
riser_height = self._get_first_riser_height(props) riser_height = self._get_first_riser_height(props)
translation = Matrix.Translation(Vector((props.tread_run, props.width / 2, riser_height))) return self.compose_gizmo_matrix(V_(0, props.width / 2, riser_height), (-1, 0, 0))
rotation = self.get_axis_rotation_matrix((1, 0, 0))
return translation @ rotation
def get_gizmo_matrix_tread_depth(self, props): def get_dimension_matrix_tread_depth(self, props) -> Matrix:
riser_height = self._get_first_riser_height(props) riser_height = self._get_first_riser_height(props)
translation = Matrix.Translation( return self.compose_gizmo_matrix(V_(0, 0, riser_height), (0, 0, -1))
Vector((props.tread_run / 2, props.width / 2, riser_height - props.tread_depth))
)
rotation = self.get_axis_rotation_matrix((0, 0, -1))
return translation @ rotation
def get_gizmo_matrix_nosing_length(self, props): 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) riser_height = self._get_first_riser_height(props)
translation = Matrix.Translation(Vector((-props.nosing_length, props.width / 2, riser_height))) return self.compose_gizmo_matrix(V_(-props.nosing_length, props.width / 2, riser_height), (0, 0, -1))
rotation = self.get_axis_rotation_matrix((-1, 0, 0))
return translation @ rotation
def get_gizmo_matrix_nosing_depth(self, props): def get_dimension_matrix_base_slab_depth(self, props) -> Matrix:
riser_height = self._get_first_riser_height(props) return self.compose_gizmo_matrix(V_(0, props.width / 2, 0), (0, 0, -1))
translation = Matrix.Translation(
Vector((-props.nosing_length, props.width / 2, riser_height - props.nosing_depth))
)
rotation = self.get_axis_rotation_matrix((0, 0, -1))
return translation @ rotation
def get_gizmo_matrix_total_length_target(self, props): def get_dimension_matrix_top_slab_depth(self, props) -> Matrix:
translation = Matrix.Translation(Vector((props.total_length_target, props.width / 2, props.height)))
rotation = self.get_axis_rotation_matrix((1, 0, 0))
return translation @ rotation
def get_gizmo_matrix_base_slab_depth(self, props):
translation = Matrix.Translation(Vector((0, props.width / 2, -props.base_slab_depth)))
rotation = self.get_axis_rotation_matrix((0, 0, -1))
return translation @ rotation
def get_gizmo_matrix_top_slab_depth(self, props):
total_run = self._get_stair_total_run(props) total_run = self._get_stair_total_run(props)
translation = Matrix.Translation(Vector((total_run, props.width / 2, props.height - props.top_slab_depth))) return self.compose_gizmo_matrix(V_(total_run, props.width / 2, props.height), (0, 0, -1))
rotation = self.get_axis_rotation_matrix((0, 0, -1))
return translation @ rotation
def setup(self, context): def setup(self, context: bpy.types.Context) -> None:
self.setup_property_gizmos(context)
self.setup_editing_gizmos(context) self.setup_editing_gizmos(context)
self.setup_dimension_gizmos(context)
# Stair-specific gizmos
prefs = tool.Blender.get_addon_preferences() prefs = tool.Blender.get_addon_preferences()
highlight_color = prefs.decorator_color_selected[:3] highlight_color = prefs.decorator_color_selected[:3]
# Secondary width gizmo at the top of the stairs (linked to same width property)
self.gizmo_width_top = self.gizmos.new("BIM_GT_gizmo_arrow_2d")
def make_width_get():
def move_get():
obj = bpy.context.active_object
if not obj:
return 0.0
props = self.get_props(obj)
return props.width
return move_get
def make_width_set():
def move_set(value):
obj = bpy.context.active_object
if not obj:
return
props = self.get_props(obj)
props.width = max(0.0, value)
return move_set
self.gizmo_width_top.move_get_cb = make_width_get()
self.gizmo_width_top.move_set_cb = make_width_set()
self.gizmo_width_top.axis = Vector((0, 1, 0))
self.gizmo_width_top.local_axis = Vector((0, 1, 0))
self.gizmo_width_top.invert_delta = False
self.gizmo_width_top.delta_scale = 1.0
self.gizmo_width_top.prop_name = "Width"
self.gizmo_width_top.gizmo_group = self
self.gizmo_width_top.color = self.COLOR_GREEN
self.gizmo_width_top.color_highlight = highlight_color
self.gizmo_width_top.alpha = 0.99
self.gizmo_width_top.use_draw_modal = True
self.gizmo_width_top.use_draw_scale = True
# Total length lock gizmo
self.lock_gizmo = self.gizmos.new("VIEW3D_GT_lock") self.lock_gizmo = self.gizmos.new("VIEW3D_GT_lock")
self.lock_gizmo.use_draw_scale = False self.lock_gizmo.use_draw_scale = False
self.lock_gizmo.color = self.COLOR_BLUE self.lock_gizmo.color = self.COLOR_BLUE
@@ -546,7 +667,14 @@ class GizmoStairEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
self.lock_gizmo.prop_path = "BIMStairProperties.total_length_lock" self.lock_gizmo.prop_path = "BIMStairProperties.total_length_lock"
self.lock_gizmo.target_set_operator("bim.toggle_stair_total_length_lock") self.lock_gizmo.target_set_operator("bim.toggle_stair_total_length_lock")
# Plus gizmo for increasing treads 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 = self.gizmos.new("VIEW3D_GT_plus")
self.plus_gizmo.use_draw_scale = False self.plus_gizmo.use_draw_scale = False
self.plus_gizmo.color = self.COLOR_GREEN self.plus_gizmo.color = self.COLOR_GREEN
@@ -555,7 +683,6 @@ class GizmoStairEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
op = self.plus_gizmo.target_set_operator("bim.adjust_stair_treads") op = self.plus_gizmo.target_set_operator("bim.adjust_stair_treads")
op.increment = 1 op.increment = 1
# Minus gizmo for decreasing treads
self.minus_gizmo = self.gizmos.new("VIEW3D_GT_minus") self.minus_gizmo = self.gizmos.new("VIEW3D_GT_minus")
self.minus_gizmo.use_draw_scale = False self.minus_gizmo.use_draw_scale = False
self.minus_gizmo.color = self.COLOR_RED self.minus_gizmo.color = self.COLOR_RED
@@ -564,16 +691,9 @@ class GizmoStairEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
op = self.minus_gizmo.target_set_operator("bim.adjust_stair_treads") op = self.minus_gizmo.target_set_operator("bim.adjust_stair_treads")
op.increment = -1 op.increment = -1
# Cycle gizmo for stair type def refresh(self, context: bpy.types.Context) -> None:
default_color = prefs.decorations_colour[:3] if not self.is_setup_complete():
self.cycle_gizmo = self.gizmos.new("VIEW3D_GT_cycle") return
self.cycle_gizmo.use_draw_scale = False
self.cycle_gizmo.color = default_color
self.cycle_gizmo.color_highlight = highlight_color
self.cycle_gizmo.alpha = 0.8
self.cycle_gizmo.target_set_operator("bim.cycle_stair_type")
def refresh(self, context):
obj = context.active_object obj = context.active_object
if not obj: if not obj:
return return
@@ -581,26 +701,17 @@ class GizmoStairEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
props = self.get_props(obj) props = self.get_props(obj)
mw = obj.matrix_world mw = obj.matrix_world
billboard_rot = gizmo.get_billboard_rotation(context) billboard_rot = gizmo.get_billboard_rotation(context)
self.update_property_gizmos(mw, props)
self.update_editing_gizmos(context, mw, props) self.update_editing_gizmos(context, mw, props)
self.update_width_top_gizmo(mw, props)
self.update_lock_gizmo(mw, props, billboard_rot) self.update_lock_gizmo(mw, props, billboard_rot)
self.update_tread_count_gizmos(mw, props, billboard_rot) self.update_tread_lock_gizmo(props)
self.update_cycle_gizmo(mw, props, billboard_rot) self.update_tread_count_gizmos(props)
self.update_dimension_gizmos(mw, props)
def update_width_top_gizmo(self, mw, props): def update_lock_gizmo(self, mw: Matrix, props, billboard_rot: Matrix) -> None:
"""Update the secondary width gizmo at the top of the stairs.""" if self.is_gizmo_hidden_by_modal(self.lock_gizmo):
gizmo_prefs = self.get_gizmo_prefs() self.lock_gizmo.hide = True
# Use same visibility as the main width gizmo
self.gizmo_width_top.hide = not props.is_editing or not getattr(gizmo_prefs, "width", True)
if self.gizmo_width_top.hide:
return return
self.gizmo_width_top.matrix_basis = mw @ self.get_gizmo_matrix_width_top(props)
self.gizmo_width_top.matrix_offset = Matrix.Scale(self.ARROW_SCALE, 4)
def update_lock_gizmo(self, mw, props, billboard_rot):
gizmo_prefs = self.get_gizmo_prefs() gizmo_prefs = self.get_gizmo_prefs()
self.lock_gizmo.hide = not props.is_editing or not gizmo_prefs.lock self.lock_gizmo.hide = not props.is_editing or not gizmo_prefs.lock
@@ -610,75 +721,146 @@ class GizmoStairEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
self.lock_gizmo.color = self.COLOR_RED if props.total_length_lock else self.COLOR_GREEN 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 = self._get_stair_total_run(props)
lock_x = total_run + props.tread_run + self.GIZMO_X_OFFSET
local_transform = ( local_transform = (
Matrix.Translation(Vector((lock_x, props.width / 2, props.height))) Matrix.Translation(Vector((total_run + 0.5, -self.GIZMO_OFFSET, -self.GIZMO_OFFSET)))
@ billboard_rot @ billboard_rot
@ Matrix.Scale(0.2, 4) @ Matrix.Scale(self.EDITING_ICON_SCALE, 4)
) )
self.lock_gizmo.matrix_basis = mw @ local_transform self.lock_gizmo.matrix_basis = mw @ local_transform
def update_tread_count_gizmos(self, mw, props, billboard_rot): def update_tread_lock_gizmo(self, props) -> None:
gizmo_prefs = self.get_gizmo_prefs() """Update visibility of tread lock gizmo. Positioning is handled in _update_editing_icon_positions."""
if not hasattr(self, "tread_lock_gizmo"):
self.plus_gizmo.hide = not props.is_editing or not gizmo_prefs.plus
self.minus_gizmo.hide = not props.is_editing or props.number_of_treads <= 1 or not gizmo_prefs.minus
if self.plus_gizmo.hide and self.minus_gizmo.hide:
return return
total_run = self._get_stair_total_run(props) if self.is_gizmo_hidden_by_modal(self.tread_lock_gizmo):
base_x = total_run + props.tread_run + self.GIZMO_X_OFFSET self.tread_lock_gizmo.hide = True
if not self.plus_gizmo.hide:
plus_local_transform = (
Matrix.Translation(Vector((
base_x + self.GIZMO_PLUS_X_OFFSET,
props.width / 2,
props.height + self.GIZMO_BUTTON_Z_OFFSET
)))
@ billboard_rot
@ Matrix.Scale(0.2, 4)
)
self.plus_gizmo.matrix_basis = mw @ plus_local_transform
if not self.minus_gizmo.hide:
minus_local_transform = (
Matrix.Translation(Vector((
base_x + self.GIZMO_MINUS_X_OFFSET,
props.width / 2,
props.height + self.GIZMO_BUTTON_Z_OFFSET
)))
@ billboard_rot
@ Matrix.Scale(0.2, 4)
)
self.minus_gizmo.matrix_basis = mw @ minus_local_transform
def update_cycle_gizmo(self, mw, props, billboard_rot):
gizmo_prefs = self.get_gizmo_prefs()
self.cycle_gizmo.hide = not props.is_editing or not gizmo_prefs.cycle
if self.cycle_gizmo.hide:
return return
gizmo_prefs = self.get_gizmo_prefs()
self.tread_lock_gizmo.hide = not props.is_editing or not gizmo_prefs.lock
def update_tread_count_gizmos(self, props) -> 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
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:
"""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) total_run = self._get_stair_total_run(props)
cycle_x = total_run + props.tread_run + self.GIZMO_X_OFFSET riser_height = self._get_first_riser_height(props)
local_transform = (
Matrix.Translation(Vector(( self._update_overall_dimension_gizmos(mw, props, viewing_from_negative_y, viewing_from_negative_x, total_run)
cycle_x, self._update_tread_dimension_gizmos(mw, props, viewing_from_negative_y, total_run, riser_height)
props.width / 2, self._update_detail_dimension_gizmos(mw, props, viewing_from_negative_y, riser_height)
props.height + self.GIZMO_CYCLE_Z_OFFSET self._update_lock_gizmo_position(mw, props, viewing_from_negative_y, billboard_rot, total_run)
))) self._update_editing_icon_positions(mw, props, viewing_from_negative_y, billboard_rot)
@ billboard_rot
@ Matrix.Scale(0.2, 4) def _update_overall_dimension_gizmos(
self, mw: Matrix, props, 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)
)
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)
)
def _update_tread_dimension_gizmos(
self, mw: Matrix, props, 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)
)
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)
)
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)
)
def _update_detail_dimension_gizmos(
self, mw: Matrix, props, 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)
self.set_dimension_gizmo_position("nosing_length", mw, V_(0, props.width / 2, riser_height), (-1, 0, 0))
self.set_dimension_gizmo_position("tread_depth", mw, V_(0, y_pos, riser_height), (0, 0, -1))
self.set_dimension_gizmo_position("riser_height", mw, V_(props.tread_run, y_pos, 0), (0, 0, 1))
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
) -> 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
) )
self.cycle_gizmo.matrix_basis = mw @ local_transform
def draw_prepare(self, context): def _update_editing_icon_positions(self, mw, props, viewing_from_negative_y, billboard_rot):
"""Called before drawing - updates gizmos to face camera.""" """Update editing icon positions, flipping Y based on viewing angle."""
# Call base class implementation if not props.is_editing:
super().draw_prepare(context) return
# Also update the secondary width gizmo to face camera icon_z = props.height + 0.5
if hasattr(self, "gizmo_width_top") and not self.gizmo_width_top.hide: y_pos = -self.GIZMO_OFFSET if viewing_from_negative_y else props.width + self.GIZMO_OFFSET
self.gizmo_width_top.draw_prepare(context)
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(
"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
)
self.set_icon_gizmo_position(
"plus_gizmo", mw, self.ICON_PLUS_X, y_pos, icon_z, billboard_rot, scale=self.ICON_PLUS_MINUS_SCALE
)
self.set_icon_gizmo_position(
"minus_gizmo", mw, self.ICON_MINUS_X, y_pos, icon_z, billboard_rot, scale=self.ICON_PLUS_MINUS_SCALE
)
+200 -149
View File
@@ -28,8 +28,8 @@ import ifcopenshell.api.pset
import bonsai.tool as tool import bonsai.tool as tool
import bonsai.core.root import bonsai.core.root
import bonsai.core.geometry import bonsai.core.geometry
from bonsai.bim import gizmo from bonsai.bim.module.drawing import gizmos as gizmo
from bonsai.bim.gizmo import GizmoPropConfig from bonsai.bim.module.drawing.gizmos import DimensionGizmoConfig
from ifcopenshell.api.geometry.add_window_representation import DEFAULT_PANEL_SCHEMAS from ifcopenshell.api.geometry.add_window_representation import DEFAULT_PANEL_SCHEMAS
import ifcopenshell.api import ifcopenshell.api
import ifcopenshell.api.material import ifcopenshell.api.material
@@ -43,6 +43,44 @@ from typing import get_args
V_ = tool.Blender.V_ 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"
def update_window_modifier_representation(context: bpy.types.Context) -> None: def update_window_modifier_representation(context: bpy.types.Context) -> None:
obj = context.active_object obj = context.active_object
@@ -481,6 +519,7 @@ class AddWindow(bpy.types.Operator, tool.Ifc.Operator):
class CancelEditingWindow(bpy.types.Operator, tool.Ifc.Operator): class CancelEditingWindow(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.cancel_editing_window" bl_idname = "bim.cancel_editing_window"
bl_label = "Cancel Editing Window" bl_label = "Cancel Editing Window"
bl_description = "Cancel editing and revert window parameters to their previous values"
bl_options = {"REGISTER"} bl_options = {"REGISTER"}
def _execute(self, context): def _execute(self, context):
@@ -509,6 +548,7 @@ class CancelEditingWindow(bpy.types.Operator, tool.Ifc.Operator):
class FinishEditingWindow(bpy.types.Operator, tool.Ifc.Operator): class FinishEditingWindow(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.finish_editing_window" bl_idname = "bim.finish_editing_window"
bl_label = "Finish Editing Window" bl_label = "Finish Editing Window"
bl_description = "Apply changes and finish editing window parameters"
bl_options = {"REGISTER"} bl_options = {"REGISTER"}
def _execute(self, context): def _execute(self, context):
@@ -541,6 +581,7 @@ class FinishEditingWindow(bpy.types.Operator, tool.Ifc.Operator):
class EnableEditingWindow(bpy.types.Operator, tool.Ifc.Operator): class EnableEditingWindow(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.enable_editing_window" bl_idname = "bim.enable_editing_window"
bl_label = "Enable Editing Window" bl_label = "Enable Editing Window"
bl_description = "Enter edit mode to modify window parameters interactively"
bl_options = {"REGISTER"} bl_options = {"REGISTER"}
def _execute(self, context): def _execute(self, context):
@@ -605,6 +646,26 @@ class CycleWindowType(bpy.types.Operator, tool.Ifc.Operator):
return {"FINISHED"} return {"FINISHED"}
def _compute_frame_depth(props) -> float:
"""Get the first panel's frame depth value."""
return props.frame_depth[0]
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:])
class GizmoWindowEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup): class GizmoWindowEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
bl_idname = "OBJECT_GGT_bim_window_edition" bl_idname = "OBJECT_GGT_bim_window_edition"
bl_label = "Window Editing Gizmo" bl_label = "Window Editing Gizmo"
@@ -617,190 +678,180 @@ class GizmoWindowEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
cancel_editing_operator = "bim.cancel_editing_window" cancel_editing_operator = "bim.cancel_editing_window"
cycle_type_operator = "bim.cycle_window_type" cycle_type_operator = "bim.cycle_window_type"
gizmo_props = [ dimension_gizmo_props = [
GizmoPropConfig("overall_height", (0, 0, 1)), DimensionGizmoConfig(attr_name="overall_width", axis=(1, 0, 0), min_value=0.01, text_offset_sign=-1),
GizmoPropConfig("overall_width", (1, 0, 0)), DimensionGizmoConfig(attr_name="overall_height", axis=(0, 0, 1), min_value=0.01, text_alignment="start"),
GizmoPropConfig("lining_depth", (0, 1, 0)), DimensionGizmoConfig(attr_name="lining_offset", axis=(0, 1, 0)),
GizmoPropConfig("lining_thickness", (1, 0, 0)), DimensionGizmoConfig(attr_name="lining_depth", axis=(0, 1, 0)),
GizmoPropConfig("lining_to_panel_offset_x", (1, 0, 0)), DimensionGizmoConfig(attr_name="lining_thickness", axis=(1, 0, 0)),
GizmoPropConfig("lining_to_panel_offset_y", (0, 1, 0)), DimensionGizmoConfig(attr_name="lining_to_panel_offset_x", axis=(1, 0, 0)),
GizmoPropConfig("mullion_thickness", (1, 0, 0), delta_scale=2.0), DimensionGizmoConfig(attr_name="lining_to_panel_offset_y", axis=(0, 1, 0), min_value=-10.0),
GizmoPropConfig("first_mullion_offset", (1, 0, 0)), DimensionGizmoConfig(
GizmoPropConfig("second_mullion_offset", (1, 0, 0)), attr_name="frame_depth",
GizmoPropConfig("transom_thickness", (0, 0, 1), delta_scale=2.0), axis=(0, -1, 0),
GizmoPropConfig("first_transom_offset", (0, 0, 1)), compute_value=_compute_frame_depth,
GizmoPropConfig("second_transom_offset", (0, 0, 1)), apply_value=_apply_frame_depth,
),
DimensionGizmoConfig(
attr_name="frame_thickness",
axis=(1, 0, 0),
compute_value=_compute_frame_thickness,
apply_value=_apply_frame_thickness,
),
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),
] ]
@classmethod @classmethod
def is_element_type(cls, element) -> bool: def is_element_type(cls, element) -> bool:
return tool.Blender.Modifier.is_window(element) return tool.Blender.Modifier.is_window(element)
def get_props(self, obj): def get_props(self, obj: bpy.types.Object):
return tool.Model.get_window_props(obj) return tool.Model.get_window_props(obj)
def get_gizmo_prefs(self): def get_gizmo_prefs(self):
prefs = tool.Blender.get_addon_preferences() prefs = tool.Blender.get_addon_preferences()
return prefs.gizmos.window return prefs.gizmos.window
def should_hide_gizmo(self, attr_name, props): def get_icon_y_offset(self, context: bpy.types.Context, mw: Matrix) -> float:
"""Window-specific visibility rules for gizmos.""" """Position icons beyond the furthest geometry extent based on view direction."""
if not props.is_editing: obj = context.active_object
return True if not obj:
return self.ICON_Y_OFFSET
props = self.get_props(obj)
has_mullion = self._has_mullion(props) furthest_positive_y = (
has_second_mullion = self._has_second_mullion(props) max(0, props.lining_offset)
has_transom = self._has_transom(props) + props.lining_depth
has_second_transom = self._has_second_transom(props) + props.lining_to_panel_offset_y
if attr_name == "mullion_thickness" and not has_mullion:
return True
if attr_name == "first_mullion_offset" and not has_mullion:
return True
if attr_name == "second_mullion_offset" and not has_second_mullion:
return True
if attr_name == "transom_thickness" and not has_transom:
return True
if attr_name == "first_transom_offset" and not has_transom:
return True
if attr_name == "second_transom_offset" and not has_second_transom:
return True
return False
def get_gizmo_matrix_overall_height(self, props):
translation = Matrix.Translation(V_(props.overall_width - 0.05, props.lining_offset, props.overall_height))
rotation = self.get_axis_rotation_matrix((0, 0, 1))
return translation @ rotation
def get_gizmo_matrix_overall_width(self, props):
translation = Matrix.Translation(V_(props.overall_width, props.lining_offset, props.overall_height - 0.05))
rotation = self.get_axis_rotation_matrix((1, 0, 0))
return translation @ rotation
def get_gizmo_matrix_lining_depth(self, props):
translation = Matrix.Translation(
V_(props.overall_width / 2, props.lining_depth + props.lining_offset, props.overall_height)
) )
rotation = self.get_axis_rotation_matrix((0, 1, 0)) furthest_negative_y = min(0, props.lining_offset)
return translation @ rotation
def get_gizmo_matrix_lining_thickness(self, props): viewing_from_negative_y, _ = self.get_local_view_direction(context, mw)
translation = Matrix.Translation( if viewing_from_negative_y:
V_(props.lining_thickness, props.lining_depth / 2 + props.lining_offset, props.overall_height / 2) return furthest_negative_y - 2 * self.GIZMO_OFFSET
return furthest_positive_y + 2 * self.GIZMO_OFFSET
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)
) )
rotation = self.get_axis_rotation_matrix((1, 0, 0))
return translation @ rotation
@staticmethod @staticmethod
def _get_lining_to_panel_offset_y_full(props) -> float: def _get_lining_to_panel_offset_y_full(props) -> float:
"""Get the full Y offset for lining-to-panel positioning.""" """Get the full Y offset for lining-to-panel positioning."""
return (props.lining_depth - props.frame_depth[0]) + props.lining_to_panel_offset_y return (props.lining_depth - props.frame_depth[0]) + props.lining_to_panel_offset_y
def get_gizmo_matrix_lining_to_panel_offset_x(self, props): def get_dimension_matrix_lining_to_panel_offset_x(self, props) -> Matrix:
y_full = self._get_lining_to_panel_offset_y_full(props) y_full = self._get_lining_to_panel_offset_y_full(props)
translation = Matrix.Translation( return self.compose_gizmo_matrix(
V_(props.lining_to_panel_offset_x, y_full + props.lining_offset, props.lining_thickness) V_(0, y_full + props.frame_depth[0] + props.lining_offset, props.lining_to_panel_offset_x), (1, 0, 0)
) )
rotation = self.get_axis_rotation_matrix((1, 0, 0))
return translation @ rotation
def get_gizmo_matrix_lining_to_panel_offset_y(self, props): 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_full = self._get_lining_to_panel_offset_y_full(props)
translation = Matrix.Translation( y_start = y_full + props.frame_depth[0] + props.lining_offset
V_( return self.compose_gizmo_matrix(
props.lining_to_panel_offset_x, V_(props.lining_to_panel_offset_x, y_start, props.lining_to_panel_offset_x), (0, -1, 0)
y_full + props.frame_depth[0] + props.lining_offset,
props.lining_thickness,
)
)
rotation = self.get_axis_rotation_matrix((0, 1, 0))
return translation @ rotation
def get_gizmo_matrix_mullion_thickness(self, props):
# Position at the right edge of the mullion (offset + thickness/2) so gizmo movement matches property 1:1
translation = Matrix.Translation(
V_(props.first_mullion_offset + props.mullion_thickness / 2, props.lining_offset, props.overall_height / 2)
)
rotation = self.get_axis_rotation_matrix((1, 0, 0))
return translation @ rotation
def get_gizmo_matrix_first_mullion_offset(self, props):
# Position at the left edge of the mullion (offset - thickness/2)
translation = Matrix.Translation(
V_(props.first_mullion_offset - props.mullion_thickness / 2, props.lining_offset, props.overall_height / 2)
)
rotation = self.get_axis_rotation_matrix((1, 0, 0))
return translation @ rotation
def get_gizmo_matrix_second_mullion_offset(self, props):
translation = Matrix.Translation(V_(props.second_mullion_offset, props.lining_offset, props.overall_height / 2))
rotation = self.get_axis_rotation_matrix((1, 0, 0))
return translation @ rotation
def get_gizmo_matrix_transom_thickness(self, props):
# Position at the top edge of the transom (offset + thickness/2) so gizmo movement matches property 1:1
translation = Matrix.Translation(
V_(props.overall_width / 2, props.lining_offset, props.first_transom_offset + props.transom_thickness / 2)
)
rotation = self.get_axis_rotation_matrix((0, 0, 1))
return translation @ rotation
def get_gizmo_matrix_first_transom_offset(self, props):
# Position at the bottom edge of the transom (offset - thickness/2)
translation = Matrix.Translation(
V_(props.overall_width / 2, props.lining_offset, props.first_transom_offset - props.transom_thickness / 2)
)
rotation = self.get_axis_rotation_matrix((0, 0, 1))
return translation @ rotation
def get_gizmo_matrix_second_transom_offset(self, props):
translation = Matrix.Translation(V_(props.overall_width / 2, props.lining_offset, props.second_transom_offset))
rotation = self.get_axis_rotation_matrix((0, 0, 1))
return translation @ rotation
def _has_mullion(self, props):
"""Check if the window type uses mullions (vertical dividers)."""
window_type = props.window_type
return window_type in (
"DOUBLE_PANEL_VERTICAL",
"TRIPLE_PANEL_BOTTOM",
"TRIPLE_PANEL_TOP",
"TRIPLE_PANEL_LEFT",
"TRIPLE_PANEL_RIGHT",
"TRIPLE_PANEL_VERTICAL",
) )
def _has_second_mullion(self, props): def get_dimension_matrix_frame_thickness(self, props) -> Matrix:
"""Check if the window type uses a second mullion.""" y_full = self._get_lining_to_panel_offset_y_full(props)
return props.window_type == "TRIPLE_PANEL_VERTICAL" y_pos = y_full + props.frame_depth[0] + props.lining_offset
return self.compose_gizmo_matrix(
def _has_transom(self, props): V_(props.lining_to_panel_offset_x, y_pos, props.overall_height / 2), (1, 0, 0)
"""Check if the window type uses transoms (horizontal dividers)."""
window_type = props.window_type
return window_type in (
"DOUBLE_PANEL_HORIZONTAL",
"TRIPLE_PANEL_BOTTOM",
"TRIPLE_PANEL_TOP",
"TRIPLE_PANEL_LEFT",
"TRIPLE_PANEL_RIGHT",
"TRIPLE_PANEL_HORIZONTAL",
) )
def _has_second_transom(self, props): def get_dimension_matrix_mullion_thickness(self, props) -> Matrix:
"""Check if the window type uses a second transom.""" return self.compose_gizmo_matrix(
return props.window_type == "TRIPLE_PANEL_HORIZONTAL" V_(props.first_mullion_offset - props.mullion_thickness / 2, props.lining_offset, props.overall_height / 2),
(1, 0, 0),
)
def setup(self, context): def get_dimension_matrix_first_mullion_offset(self, props) -> Matrix:
# Use base class methods for common gizmos return self.compose_gizmo_matrix(
self.setup_property_gizmos(context) 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_editing_gizmos(context)
self.setup_dimension_gizmos(context)
def refresh(self, context): def refresh(self, context: bpy.types.Context) -> None:
if not self.is_setup_complete():
return
obj = context.active_object obj = context.active_object
if not obj: if not obj:
return return
props = self.get_props(obj) props = self.get_props(obj)
mw = obj.matrix_world mw = obj.matrix_world
self.update_property_gizmos(mw, props)
self.update_editing_gizmos(context, 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:
"""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))
+18 -4
View File
@@ -249,6 +249,8 @@ class GizmoPreferencesDoor(bpy.types.PropertyGroup):
overall_width: BoolProperty(name="Overall Width", default=True) overall_width: BoolProperty(name="Overall Width", default=True)
threshold_thickness: BoolProperty(name="Threshold Thickness", default=True) threshold_thickness: BoolProperty(name="Threshold Thickness", default=True)
threshold_depth: BoolProperty(name="Threshold Depth", default=True) threshold_depth: BoolProperty(name="Threshold Depth", default=True)
threshold_offset: BoolProperty(name="Threshold Offset", default=True)
lining_offset: BoolProperty(name="Lining Offset", default=True)
lining_depth: BoolProperty(name="Lining Depth", default=True) lining_depth: BoolProperty(name="Lining Depth", default=True)
lining_thickness: BoolProperty(name="Lining Thickness", default=True) lining_thickness: BoolProperty(name="Lining Thickness", default=True)
transom_offset: BoolProperty(name="Transom Offset", default=True) transom_offset: BoolProperty(name="Transom Offset", default=True)
@@ -263,6 +265,8 @@ class GizmoPreferencesDoor(bpy.types.PropertyGroup):
overall_width: bool overall_width: bool
threshold_thickness: bool threshold_thickness: bool
threshold_depth: bool threshold_depth: bool
threshold_offset: bool
lining_offset: bool
lining_depth: bool lining_depth: bool
lining_thickness: bool lining_thickness: bool
transom_offset: bool transom_offset: bool
@@ -278,10 +282,13 @@ class GizmoPreferencesWindow(bpy.types.PropertyGroup):
overall_height: BoolProperty(name="Overall Height", default=True) overall_height: BoolProperty(name="Overall Height", default=True)
overall_width: BoolProperty(name="Overall Width", default=True) overall_width: BoolProperty(name="Overall Width", default=True)
lining_offset: BoolProperty(name="Lining Offset", default=True)
lining_depth: BoolProperty(name="Lining Depth", default=True) lining_depth: BoolProperty(name="Lining Depth", default=True)
lining_thickness: BoolProperty(name="Lining Thickness", default=True) lining_thickness: BoolProperty(name="Lining Thickness", default=True)
lining_to_panel_offset_x: BoolProperty(name="Lining to Panel Offset X", default=True) lining_to_panel_offset_x: BoolProperty(name="Lining to Panel Offset X", default=True)
lining_to_panel_offset_y: BoolProperty(name="Lining to Panel Offset Y", default=True) lining_to_panel_offset_y: BoolProperty(name="Lining to Panel Offset Y", default=True)
frame_depth: BoolProperty(name="Frame Depth", default=True)
frame_thickness: BoolProperty(name="Frame Thickness", default=True)
mullion_thickness: BoolProperty(name="Mullion Thickness", default=True) mullion_thickness: BoolProperty(name="Mullion Thickness", default=True)
first_mullion_offset: BoolProperty(name="First Mullion Offset", default=True) first_mullion_offset: BoolProperty(name="First Mullion Offset", default=True)
second_mullion_offset: BoolProperty(name="Second Mullion Offset", default=True) second_mullion_offset: BoolProperty(name="Second Mullion Offset", default=True)
@@ -292,10 +299,13 @@ class GizmoPreferencesWindow(bpy.types.PropertyGroup):
if TYPE_CHECKING: if TYPE_CHECKING:
overall_height: bool overall_height: bool
overall_width: bool overall_width: bool
lining_offset: bool
lining_depth: bool lining_depth: bool
lining_thickness: bool lining_thickness: bool
lining_to_panel_offset_x: bool lining_to_panel_offset_x: bool
lining_to_panel_offset_y: bool lining_to_panel_offset_y: bool
frame_depth: bool
frame_thickness: bool
mullion_thickness: bool mullion_thickness: bool
first_mullion_offset: bool first_mullion_offset: bool
second_mullion_offset: bool second_mullion_offset: bool
@@ -311,6 +321,7 @@ class GizmoPreferencesStair(bpy.types.PropertyGroup):
height: BoolProperty(name="Height", default=True) height: BoolProperty(name="Height", default=True)
tread_run: BoolProperty(name="Tread Run", default=True) tread_run: BoolProperty(name="Tread Run", default=True)
tread_depth: BoolProperty(name="Tread Depth", default=True) tread_depth: BoolProperty(name="Tread Depth", default=True)
riser_height: BoolProperty(name="Riser Height", default=True)
nosing_length: BoolProperty(name="Nosing Length", default=True) nosing_length: BoolProperty(name="Nosing Length", default=True)
nosing_depth: BoolProperty(name="Nosing Depth", default=True) nosing_depth: BoolProperty(name="Nosing Depth", default=True)
total_length_target: BoolProperty(name="Total Length Target", default=True) total_length_target: BoolProperty(name="Total Length Target", default=True)
@@ -326,6 +337,7 @@ class GizmoPreferencesStair(bpy.types.PropertyGroup):
height: bool height: bool
tread_run: bool tread_run: bool
tread_depth: bool tread_depth: bool
riser_height: bool
nosing_length: bool nosing_length: bool
nosing_depth: bool nosing_depth: bool
total_length_target: bool total_length_target: bool
@@ -763,7 +775,8 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences):
from bonsai.bim.module.model.door import GizmoDoorEdition from bonsai.bim.module.model.door import GizmoDoorEdition
door_gizmos = self.gizmos.door door_gizmos = self.gizmos.door
gizmo_prop_names = {p.attr_name for p in GizmoDoorEdition.gizmo_props} gizmo_prop_names = {p.attr_name for p in GizmoDoorEdition.dimension_gizmo_props}
# Add special gizmos not in dimension_gizmo_props
gizmo_prop_names.update(("swing_arc", "flip_arc")) gizmo_prop_names.update(("swing_arc", "flip_arc"))
for prop in door_gizmos.__annotations__: for prop in door_gizmos.__annotations__:
if prop in gizmo_prop_names: if prop in gizmo_prop_names:
@@ -773,7 +786,7 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences):
from bonsai.bim.module.model.window import GizmoWindowEdition from bonsai.bim.module.model.window import GizmoWindowEdition
window_gizmos = self.gizmos.window window_gizmos = self.gizmos.window
gizmo_prop_names = {p.attr_name for p in GizmoWindowEdition.gizmo_props} gizmo_prop_names = {p.attr_name for p in GizmoWindowEdition.dimension_gizmo_props}
for prop in window_gizmos.__annotations__: for prop in window_gizmos.__annotations__:
if prop in gizmo_prop_names: if prop in gizmo_prop_names:
layout.prop(window_gizmos, prop) layout.prop(window_gizmos, prop)
@@ -782,8 +795,9 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences):
from bonsai.bim.module.model.stair import GizmoStairEdition from bonsai.bim.module.model.stair import GizmoStairEdition
stair_gizmos = self.gizmos.stair stair_gizmos = self.gizmos.stair
gizmo_prop_names = {p.attr_name for p in GizmoStairEdition.gizmo_props} gizmo_prop_names = {p.attr_name for p in GizmoStairEdition.dimension_gizmo_props}
special_gizmo_names = {"lock", "plus", "minus"} # Add special gizmos not in dimension_gizmo_props
special_gizmo_names = {"lock", "plus", "minus", "cycle"}
for prop in stair_gizmos.__annotations__: for prop in stair_gizmos.__annotations__:
if prop in gizmo_prop_names or prop in special_gizmo_names: if prop in gizmo_prop_names or prop in special_gizmo_names:
layout.prop(stair_gizmos, prop) layout.prop(stair_gizmos, prop)
+168
View File
@@ -0,0 +1,168 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2021 Dion Moult <dion@thinkmoult.com>
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
from dataclasses import dataclass, field
from typing import Literal, Protocol
ModalResult = Literal["RUNNING_MODAL", "FINISHED", "CANCELLED"]
class IntegerInputOperator(Protocol):
"""Protocol for operators using IntegerInputState."""
_input: "IntegerInputState"
def _apply_value(self, context) -> None: ...
def _restore_value(self, context) -> None: ...
def _format_header(self) -> str: ...
@dataclass
class IntegerInputState:
"""State for keyboard integer input during modal operations."""
characters: list[str] = field(default_factory=list)
is_valid: bool = True
min_value: int = 1
_user_started_typing: bool = False
@classmethod
def from_value(cls, value: int, min_value: int = 1) -> "IntegerInputState":
"""Create state initialized with an existing value."""
return cls(
characters=list(str(value)),
is_valid=True,
min_value=min_value,
_user_started_typing=False,
)
def get_input_string(self) -> str:
return "".join(self.characters)
def get_value(self) -> int | None:
"""Return parsed integer value, or None if invalid."""
if not self.characters:
return None
try:
value = int(self.get_input_string())
return value if value >= self.min_value else None
except ValueError:
return None
def handle_digit(self, digit: str) -> None:
"""Handle a digit key press."""
if not self._user_started_typing:
self.characters.clear()
self._user_started_typing = True
self.characters.append(digit)
self._update_validity()
def handle_backspace(self) -> None:
"""Handle backspace key press."""
self._user_started_typing = True
if self.characters:
self.characters.pop()
self._update_validity()
def _update_validity(self) -> None:
"""Update is_valid based on current input."""
self.is_valid = self.get_value() is not None
def handle_numeric_event(state: IntegerInputState, event) -> tuple[bool, ModalResult | None]:
"""
Process a Blender event for numeric input.
Returns:
(handled, result):
- handled: True if the event was consumed
- result: "FINISHED", "CANCELLED", or None if still running
"""
if event.value != "PRESS":
return False, None
# Digit input
if event.ascii and event.ascii in "0123456789":
state.handle_digit(event.ascii)
return True, None
# Backspace
if event.type == "BACK_SPACE":
state.handle_backspace()
return True, None
# Confirm
if event.type in {"RET", "NUMPAD_ENTER"}:
if state.is_valid:
return True, "FINISHED"
return True, None # Invalid - don't confirm yet
# Click confirm
if event.type == "LEFTMOUSE":
if state.characters and state.is_valid:
return True, "FINISHED"
return True, "FINISHED" # Empty or invalid - still finish
# Cancel
if event.type in {"ESC", "RIGHTMOUSE"}:
return True, "CANCELLED"
return False, None
def run_integer_input_modal(op: IntegerInputOperator, context, event) -> set[str]:
"""
Run the modal loop for an integer input operator.
The operator must implement:
- _input: IntegerInputState
- _apply_value(context): Apply the current input value
- _restore_value(context): Restore original value on cancel
- _format_header(): Return the header string to display
Returns the Blender modal return set (e.g., {"RUNNING_MODAL"}).
"""
handled, result = handle_numeric_event(op._input, event)
if handled:
if result == "FINISHED":
op._apply_value(context)
cleanup_header(context)
return {"FINISHED"}
elif result == "CANCELLED":
op._restore_value(context)
cleanup_header(context)
return {"CANCELLED"}
else:
op._apply_value(context)
update_header(context, op._format_header())
return {"RUNNING_MODAL"}
return {"RUNNING_MODAL"}
def update_header(context, text: str) -> None:
"""Update the area header text."""
if context.area:
context.area.header_text_set(text)
def cleanup_header(context) -> None:
"""Clear the area header text."""
if context.area:
context.area.header_text_set(None)